From 8a28c9cfe301b2b5963031d90ec88d41140f5735 Mon Sep 17 00:00:00 2001 From: Akshat Singhal <65562230+akshat-kumar-singhal@users.noreply.github.com> Date: Thu, 13 Aug 2026 21:33:07 +0530 Subject: [PATCH 01/20] Merge pull request #3815 from akshat-kumar-singhal/fix/flaky-cron-nil-logger-sql-mock-3813 fix(cron): join in-flight jobs on Stop and skip jobs with no logger --- docs/guides/graceful-shutdown/page.md | 14 ++-- pkg/gofr/cron.go | 70 ++++++++++++++-- pkg/gofr/cron_test.go | 110 +++++++++++++++++++++++--- pkg/gofr/datasource/sql/sql_test.go | 76 +++++++++++++----- pkg/gofr/gofr.go | 4 +- pkg/gofr/gofr_test.go | 11 ++- 6 files changed, 243 insertions(+), 42 deletions(-) diff --git a/docs/guides/graceful-shutdown/page.md b/docs/guides/graceful-shutdown/page.md index e799e5c49d..d077a19662 100644 --- a/docs/guides/graceful-shutdown/page.md +++ b/docs/guides/graceful-shutdown/page.md @@ -9,7 +9,7 @@ nextjs: # Graceful Shutdown {% answer %} -GoFr listens for `SIGINT` and `SIGTERM` and, on either signal, runs `App.Shutdown` which calls `Shutdown` on the HTTP, gRPC, and metrics servers and `Close` on the container's datasource connections. The shutdown is bounded by `SHUTDOWN_GRACE_PERIOD` (default `30s`); if it expires the process exits with whatever connections remain. Pair this with Kubernetes' `terminationGracePeriodSeconds` and a small `preStop` sleep to avoid losing in-flight requests during rolling restarts. +GoFr listens for `SIGINT` and `SIGTERM` and, on either signal, runs `App.Shutdown` which calls `Shutdown` on the HTTP, gRPC, and metrics servers, stops the cron scheduler and waits for jobs already running, then calls `Close` on the container's datasource connections. The shutdown is bounded by `SHUTDOWN_GRACE_PERIOD` (default `30s`); if it expires the process exits with whatever connections remain. Pair this with Kubernetes' `terminationGracePeriodSeconds` and a small `preStop` sleep to avoid losing in-flight requests during rolling restarts. {% /answer %} ## When to use @@ -24,15 +24,17 @@ Every production GoFr deployment on Kubernetes should be configured for graceful ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGINT, syscall.SIGTERM) ``` -When that context is canceled, a goroutine creates a timeout context using `SHUTDOWN_GRACE_PERIOD` (default `30s`) and calls `App.Shutdown`. The order is fixed by the framework — see [`pkg/gofr/gofr.go:96-114`](https://github.com/gofr-dev/gofr/blob/main/pkg/gofr/gofr.go) — and `Shutdown` joins errors from each step: +When that context is canceled, a goroutine creates a timeout context using `SHUTDOWN_GRACE_PERIOD` (default `30s`) and calls `App.Shutdown`. The order is fixed by the framework — see [`pkg/gofr/gofr.go`](https://github.com/gofr-dev/gofr/blob/main/pkg/gofr/gofr.go) — and `Shutdown` joins errors from each step: 1. `httpServer.Shutdown(ctx)` — stops accepting new connections, waits for in-flight handlers 2. `grpcServer.Shutdown(ctx)` — drains active streams -3. `container.Close()` — closes SQL pools, Redis clients, Pub/Sub consumers, and other registered datasources +3. Cron stop — halts the scheduler and waits for jobs already running, bounded by the shutdown deadline 4. `metricServer.Shutdown(ctx)` — stops `/metrics` -5. Logger close — if the logger implements `io.Closer`, its `Close()` is called last +5. `mcpServer.Shutdown(ctx)` — stops the MCP server +6. `container.ShutdownMetrics(ctx)` and `container.Close()` — closes SQL pools, Redis clients, Pub/Sub consumers, and other registered datasources +7. Logger close — if the logger implements `io.Closer`, its `Close()` is called last -The container's `Close` is what commits Pub/Sub offsets and lets SQL drivers finish in-progress queries. Application code does not need to coordinate this order. +The container's `Close` is what commits Pub/Sub offsets and lets SQL drivers finish in-progress queries. It runs second-to-last on purpose: every producer of datasource traffic — handlers, streams, cron jobs — is drained before the connections they use are torn down. Application code does not need to coordinate this order. ## OnStart hooks vs shutdown hooks @@ -87,7 +89,7 @@ For a service with 2s P99, that's 5s + 30s + 10s = 45–60s. - **SQL.** `database/sql` waits for active queries to finish on `Close()`. Long-running transactions can extend shutdown — keep request timeouts shorter than `SHUTDOWN_GRACE_PERIOD`. - **Redis / NoSQL.** Clients close idle connections immediately and wait for in-flight commands. - **Pub/Sub.** GoFr's subscription manager respects the shutdown context — consumers stop polling and commit current offsets where the broker supports it (Kafka, NATS JetStream). -- **Cron jobs.** GoFr's `App.Shutdown` drains HTTP, gRPC, and metrics servers and closes datasource connections — it does **not** stop the cron scheduler or wait for in-flight cron tasks. Cron jobs run with `context.Background()`, so they continue past SIGTERM and may be cut off when the container is killed at `terminationGracePeriodSeconds`. If you have long-running cron work that must finish, run it as a separate Kubernetes `Job` triggered by a `CronJob` resource instead of inside the same pod, so the pod's lifecycle doesn't interrupt it. +- **Cron jobs.** `App.Shutdown` stops the cron scheduler and waits for jobs that are already running before it closes datasource connections, so a job mid-write is not cut off by `container.Close()`. The wait is bounded by the shutdown deadline (`SHUTDOWN_GRACE_PERIOD`): if a job is still running when that expires, `Shutdown` returns and the job is abandoned. The jobs themselves run with `context.Background()`, so a job's own `ctx` is never cancelled — anything that needs to be interruptible has to watch something else. For cron work that routinely outlasts `SHUTDOWN_GRACE_PERIOD`, run it as a Kubernetes `CronJob` in its own pod rather than inside the service. ## Verification diff --git a/pkg/gofr/cron.go b/pkg/gofr/cron.go index b0e2921b89..3e6ab1e06f 100644 --- a/pkg/gofr/cron.go +++ b/pkg/gofr/cron.go @@ -35,6 +35,14 @@ type Crontab struct { container *container.Container mu sync.RWMutex + // stopped is set under mu before done is closed, so a tick that is already + // inside runScheduled cannot dispatch a new job goroutine after Stop has + // begun waiting for the in-flight ones. + stopped bool + // wg tracks the job goroutines dispatched by runScheduled so Stop can join + // them instead of leaving them to log/record metrics after their owner + // (an App, or a test's container) has been torn down. + wg sync.WaitGroup done chan struct{} once sync.Once @@ -86,30 +94,78 @@ func NewCron(cntnr *container.Container) *Crontab { return c } +// Stop halts the scheduler and blocks until every job goroutine it already +// dispatched has returned. Joining matters as much as halting: a job that fired +// on the tick just before Stop keeps logging and recording metrics against a +// container that the caller is about to tear down. func (c *Crontab) Stop() { + c.stop(nil) +} + +// stop is Stop with an optional abort channel for the join, so an application +// shutdown is never held open past its own deadline by a long-running job. +func (c *Crontab) stop(abort <-chan struct{}) { c.once.Do(func() { c.ticker.Stop() + + c.mu.Lock() + c.stopped = true + c.mu.Unlock() + close(c.done) }) + + joined := make(chan struct{}) + + go func() { + c.wg.Wait() + close(joined) + }() + + select { + case <-joined: + case <-abort: + } } func (c *Crontab) runScheduled(t time.Time) { + // The lock is held across dispatch so that Stop cannot slip between the + // stopped check and wg.Add. Dispatch only spawns goroutines, and a job that + // calls back into AddJob does so from its own goroutine, so this cannot + // deadlock. c.mu.Lock() + defer c.mu.Unlock() - n := len(c.jobs) - jb := make([]*job, n) - copy(jb, c.jobs) + if c.stopped { + return + } - c.mu.Unlock() + tk := getTick(t) - for _, j := range jb { - if j.tick(getTick(t)) { - go j.run(c.container) + for _, j := range c.jobs { + if !j.tick(tk) { + continue } + + c.wg.Add(1) + + go func(j *job) { + defer c.wg.Done() + + j.run(c.container) + }(j) } } func (j *job) run(cntnr *container.Container) { + // A job runs on its own goroutine, detached from whoever scheduled it, so a + // container that is nil or half-built (no logger) has to be handled here + // rather than assumed away — every log below would otherwise nil-panic on a + // background goroutine and take the whole process down. + if cntnr == nil || cntnr.Logger == nil { + return + } + ctx, span := otel.GetTracerProvider().Tracer("gofr-"+version.Framework). Start(context.Background(), j.name) defer span.End() diff --git a/pkg/gofr/cron_test.go b/pkg/gofr/cron_test.go index 58f5f3f866..1647ef12c3 100644 --- a/pkg/gofr/cron_test.go +++ b/pkg/gofr/cron_test.go @@ -2,6 +2,7 @@ package gofr import ( "fmt" + "sync/atomic" "testing" "time" @@ -214,15 +215,10 @@ func TestCronTab_AddJob(t *testing.T) { mocks.Metrics.EXPECT().NewCounter("app_cron_job_success", gomock.Any()).AnyTimes() mocks.Metrics.EXPECT().NewCounter("app_cron_job_failures", gomock.Any()).AnyTimes() - // These AnyTimes() runtime expectations are load-bearing, not merely - // defensive: Stop() below halts only *future* ticks, it does not join a job - // goroutine that a tick already dispatched (Crontab fires `go j.run(...)` with - // no WaitGroup). The "* * * * *" job fires at second 0 of every minute, so a - // job dispatched just before Stop can still call the metrics mock after the - // test returns; without these expectations that call is gomock's - // t.Fatalf-on-finished-test -> panic. `defer c.Stop()` narrows the window but - // cannot close it — deterministic joining needs a framework change to Stop - // (tracked in gofr-dev/gofr#3801). Do not remove these believing Stop covers it. + // Stop() now joins the job goroutines a tick already dispatched, so a job can + // no longer reach the metrics mock after the test returns. These AnyTimes() + // expectations remain because the "* * * * *" job can legitimately fire at + // second 0 of a minute *during* the test, and the count is timing-dependent. mocks.Metrics.EXPECT().IncrementCounter(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes() mocks.Metrics.EXPECT().RecordHistogram(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes() @@ -735,6 +731,102 @@ func TestCronTab_runScheduled_Panic(t *testing.T) { } } +func TestCronTab_runScheduled_NilLoggerAndContainer(t *testing.T) { + tests := []struct { + desc string + cntnr *container.Container + }{ + {"nil container", nil}, + {"container without logger", &container.Container{}}, + } + + for _, tc := range tests { + t.Run(tc.desc, func(t *testing.T) { + ran := make(chan struct{}, 1) + + c := &Crontab{ + ticker: time.NewTicker(time.Second), + container: tc.cntnr, + done: make(chan struct{}), + jobs: []*job{{ + sec: map[int]struct{}{1: {}}, + min: map[int]struct{}{1: {}}, + hour: map[int]struct{}{1: {}}, + day: map[int]struct{}{1: {}}, + month: map[int]struct{}{1: {}}, + dayOfWeek: map[int]struct{}{1: {}}, + name: "nil-logger-job", + fn: func(*Context) { ran <- struct{}{} }, + }}, + } + + // A panic here happens on the job's own goroutine and kills the test + // binary, so reaching Stop at all is the assertion. + c.runScheduled(time.Date(2024, 1, 1, 1, 1, 1, 1, time.Local)) + c.Stop() + + assert.Empty(t, ran, "job must be skipped when it cannot log") + }) + } +} + +func TestCrontab_Stop_JoinsInFlightJobs(t *testing.T) { + release := make(chan struct{}) + + var runs atomic.Int64 + + c := &Crontab{ + ticker: time.NewTicker(time.Second), + container: &container.Container{Logger: logging.NewMockLogger(logging.ERROR)}, + done: make(chan struct{}), + jobs: []*job{{ + sec: map[int]struct{}{1: {}}, + min: map[int]struct{}{1: {}}, + hour: map[int]struct{}{1: {}}, + day: map[int]struct{}{1: {}}, + month: map[int]struct{}{1: {}}, + dayOfWeek: map[int]struct{}{1: {}}, + name: "slow-job", + fn: func(*Context) { + runs.Add(1) + <-release + }, + }}, + } + + c.runScheduled(time.Date(2024, 1, 1, 1, 1, 1, 1, time.Local)) + + stopped := make(chan struct{}) + + go func() { + c.Stop() + close(stopped) + }() + + select { + case <-stopped: + t.Fatal("Stop returned while a job was still running") + case <-time.After(50 * time.Millisecond): + } + + close(release) + + select { + case <-stopped: + case <-time.After(time.Second): + t.Fatal("Stop did not return after the job finished") + } + + require.Equal(t, int64(1), runs.Load(), "Stop must return only after the dispatched job has run") + + // A tick that lands after Stop must not dispatch anything more. The second Stop joins + // whatever that tick dispatched, so the count below is read after any such job has finished. + c.runScheduled(time.Date(2024, 1, 1, 1, 1, 1, 1, time.Local)) + c.Stop() + + require.Equal(t, int64(1), runs.Load(), "a tick after Stop must not dispatch a job") +} + func TestCrontab_Stop(t *testing.T) { mockContainer, mocks := container.NewMockContainer(t) diff --git a/pkg/gofr/datasource/sql/sql_test.go b/pkg/gofr/datasource/sql/sql_test.go index c0ae5b1311..0377a9d8ae 100644 --- a/pkg/gofr/datasource/sql/sql_test.go +++ b/pkg/gofr/datasource/sql/sql_test.go @@ -4,6 +4,8 @@ import ( "errors" "fmt" "os" + "strings" + "sync" "testing" "time" @@ -350,31 +352,69 @@ func Test_sqliteErrConnLogs(t *testing.T) { } } -func Test_SQLRetryConnectionInfoLog(t *testing.T) { - logs := testutil.StdoutOutputForFunc(func() { - ctrl := gomock.NewController(t) +// waitLogger is a datasource.Logger that closes seen once a message containing +// want has been logged. It replaces capturing stdout and sleeping for a fixed +// duration: the retry goroutine only logs after its first ping fails, and that +// ping's cost is a DNS lookup on the CI host, not a constant. +type waitLogger struct { + want string + seen chan struct{} + once sync.Once +} - mockMetrics := NewMockMetrics(ctrl) - mockConfig := config.NewMockConfig(map[string]string{ - "DB_DIALECT": "postgres", - "DB_HOST": "host", - "DB_USER": "user", - "DB_PASSWORD": "password", - "DB_PORT": "3201", - "DB_NAME": "test", - }) +func newWaitLogger(want string) *waitLogger { + return &waitLogger{want: want, seen: make(chan struct{})} +} - mockLogger := logging.NewMockLogger(logging.DEBUG) +func (l *waitLogger) record(msg string) { + if strings.Contains(msg, l.want) { + l.once.Do(func() { close(l.seen) }) + } +} - mockMetrics.EXPECT().SetGauge("app_sql_open_connections", float64(0)) - mockMetrics.EXPECT().SetGauge("app_sql_inUse_connections", float64(0)) +func (l *waitLogger) Debug(args ...any) { l.record(fmt.Sprint(args...)) } +func (l *waitLogger) Debugf(f string, args ...any) { l.record(fmt.Sprintf(f, args...)) } +func (l *waitLogger) Info(args ...any) { l.record(fmt.Sprint(args...)) } +func (l *waitLogger) Infof(f string, args ...any) { l.record(fmt.Sprintf(f, args...)) } +func (l *waitLogger) Warn(args ...any) { l.record(fmt.Sprint(args...)) } +func (l *waitLogger) Warnf(f string, args ...any) { l.record(fmt.Sprintf(f, args...)) } +func (l *waitLogger) Error(args ...any) { l.record(fmt.Sprint(args...)) } +func (l *waitLogger) Errorf(f string, args ...any) { l.record(fmt.Sprintf(f, args...)) } - _ = NewSQL(mockConfig, mockLogger, mockMetrics) +func Test_SQLRetryConnectionInfoLog(t *testing.T) { + ctrl := gomock.NewController(t) - time.Sleep(100 * time.Millisecond) + mockMetrics := NewMockMetrics(ctrl) + mockConfig := config.NewMockConfig(map[string]string{ + "DB_DIALECT": "postgres", + "DB_HOST": "host", + "DB_USER": "user", + "DB_PASSWORD": "password", + "DB_PORT": "3201", + "DB_NAME": "test", }) - assert.Contains(t, logs, "retrying SQL database connection") + logger := newWaitLogger("retrying SQL database connection") + + // pushDBMetrics emits both gauges from its own goroutine and repeats every + // 10s, so neither the number of calls nor their timing relative to the end of + // this test is something the test controls. Pinning them to the default + // Times(1) is what made this flaky; the gauges are incidental here anyway. + mockMetrics.EXPECT().SetGauge("app_sql_open_connections", gomock.Any()).AnyTimes() + mockMetrics.EXPECT().SetGauge("app_sql_inUse_connections", gomock.Any()).AnyTimes() + + db := NewSQL(mockConfig, logger, mockMetrics) + require.NotNil(t, db) + + // Close stops the retry and metrics goroutines, so neither can touch the + // gomock controller after the test has finished. + t.Cleanup(func() { _ = db.Close() }) + + select { + case <-logger.seen: + case <-time.After(10 * time.Second): + t.Fatal("timed out waiting for the retry connection log") + } } func TestNewSQL_CockroachDB(t *testing.T) { diff --git a/pkg/gofr/gofr.go b/pkg/gofr/gofr.go index 4b5e7e837a..1bce51326f 100644 --- a/pkg/gofr/gofr.go +++ b/pkg/gofr/gofr.go @@ -105,7 +105,9 @@ func (a *App) Shutdown(ctx context.Context) error { } if a.cron != nil { - a.cron.Stop() + // Joins the in-flight cron jobs, but only until the shutdown deadline — + // a job that outlives it must not hold the whole shutdown open. + a.cron.stop(ctx.Done()) } if a.metricServer != nil { diff --git a/pkg/gofr/gofr_test.go b/pkg/gofr/gofr_test.go index 568f6999e8..17a89a8571 100644 --- a/pkg/gofr/gofr_test.go +++ b/pkg/gofr/gofr_test.go @@ -1123,6 +1123,10 @@ func Test_AddCronJob_Fail(t *testing.T) { }) }) + // AddCronJob starts the scheduler even when the schedule itself is rejected; + // without this the ticker goroutine outlives the test. + a.cron.Stop() + assert.Contains(t, stderr, "error adding cron job") assert.NotContains(t, stderr, "test-job-fail") } @@ -1130,13 +1134,18 @@ func Test_AddCronJob_Fail(t *testing.T) { func Test_AddCronJob_Success(t *testing.T) { pass := false a := App{ - container: &container.Container{}, + container: &container.Container{Logger: logging.NewMockLogger(logging.ERROR)}, } a.AddCronJob("* * * * *", "test-job", func(ctx *Context) { ctx.Logger.Info("test-job-success") }) + // "* * * * *" fires at second 0 of every minute. Left running, this scheduler + // outlives the test and its job goroutine logs against a container the test + // no longer owns — the nil-logger panic seen in gofr-dev/gofr#3813. + defer a.cron.Stop() + assert.Len(t, a.cron.jobs, 1) for _, j := range a.cron.jobs { From bc271ffb5a6bcd390700f5af0fa1818565b521f3 Mon Sep 17 00:00:00 2001 From: Akshat Singhal <65562230+akshat-kumar-singhal@users.noreply.github.com> Date: Thu, 13 Aug 2026 23:27:20 +0530 Subject: [PATCH 02/20] chore(ci): pin ls-lint/action to node24 commit to clear Node 20 warning (#3864) v2.3.1 declares `using: node20`, so every Linting Party run emits the Node.js 20 deprecation warning. Upstream fixed it in 0c7f19c ("chore: run on the node24 runtime"), but no release carries it yet, so pin the SHA. Revert to a tag once ls-lint cuts a release with the node24 runtime. Co-authored-by: Aryan Mehrotra --- .github/workflows/go.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 6ce9850bee..11b82af95e 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -576,7 +576,9 @@ jobs: # Check file naming conventions using ls-lint - name: Check for file names errors - uses: ls-lint/action@v2.3.1 + # Pinned to main: v2.3.1 still declares the deprecated node20 runtime. + # 0c7f19c ("chore: run on the node24 runtime") is not in a tagged release yet. + uses: ls-lint/action@0c7f19c04594e52a801dec991aae70a7ae5c6665 # main, node24 runtime with: config: .ls-lint.yml From aef162232eaa019d37c5822580118e857463a025 Mon Sep 17 00:00:00 2001 From: Akshat Singhal <65562230+akshat-kumar-singhal@users.noreply.github.com> Date: Sat, 15 Aug 2026 13:03:53 +0530 Subject: [PATCH 03/20] fix(grpc): guard the server handle so Run and Shutdown stop racing (#3929) (#3931) --- pkg/gofr/gofr_test.go | 6 ++- pkg/gofr/grpc.go | 91 ++++++++++++++++++++++++---------- pkg/gofr/grpc_test.go | 112 +++++++++++++++++++++++++++++++++++++----- 3 files changed, 168 insertions(+), 41 deletions(-) diff --git a/pkg/gofr/gofr_test.go b/pkg/gofr/gofr_test.go index 17a89a8571..b53a6af240 100644 --- a/pkg/gofr/gofr_test.go +++ b/pkg/gofr/gofr_test.go @@ -1841,8 +1841,10 @@ func TestStartGRPCServer_Registered(t *testing.T) { // Give it a moment to start then shut down time.Sleep(50 * time.Millisecond) - if app.grpcServer != nil && app.grpcServer.server != nil { - app.grpcServer.server.Stop() + // Read through getServer rather than the field: createServer publishes it from the serve + // goroutine, so an unguarded read here races that write. + if app.grpcServer != nil { + app.grpcServer.forceStop() } wg.Wait() diff --git a/pkg/gofr/grpc.go b/pkg/gofr/grpc.go index 2ae64bf386..f5d66fab24 100644 --- a/pkg/gofr/grpc.go +++ b/pkg/gofr/grpc.go @@ -8,6 +8,7 @@ import ( "reflect" "strconv" "strings" + "sync" grpc_recovery "github.com/grpc-ecosystem/go-grpc-middleware/v2/interceptors/recovery" "google.golang.org/grpc" @@ -19,6 +20,8 @@ import ( ) type grpcServer struct { + // srvMu guards server, which ensureServer writes on the serve goroutine and Shutdown reads on the caller goroutine. + srvMu sync.Mutex server *grpc.Server interceptors []grpc.UnaryServerInterceptor streamInterceptors []grpc.StreamServerInterceptor @@ -122,32 +125,58 @@ func registerGRPCMetrics(c *container.Container) { c.Metrics().NewCounter("app_grpc_rate_limit_exceeded_total", "Total gRPC requests rejected by rate limiter") } -func (g *grpcServer) createServer() error { +// ensureServer returns the server, creating it on first call. The check and the publish happen +// under a single hold — mirroring httpServer.run — so two concurrent callers cannot both observe +// nil and both build one, leaving the loser to Serve a server no later getServer sees and no +// Shutdown stops. The hold also covers the append to g.options. +// +// Building under the lock is safe: grpc.NewServer and reflection.Register do not block. The +// server is published only once fully set up, so a concurrent Shutdown sees either no server or +// a ready one, never a partially registered one. Callers use the returned copy for blocking +// calls such as Serve, which must never hold srvMu. +func (g *grpcServer) ensureServer() (*grpc.Server, error) { + g.srvMu.Lock() + defer g.srvMu.Unlock() + + if g.server != nil { + return g.server, nil + } + interceptorOption := grpc.ChainUnaryInterceptor(g.interceptors...) streamOpt := grpc.ChainStreamInterceptor(g.streamInterceptors...) g.options = append(g.options, interceptorOption, streamOpt) - g.server = grpc.NewServer(g.options...) - if g.server == nil { - return errFailedCreateServer + srv := grpc.NewServer(g.options...) + if srv == nil { + return nil, errFailedCreateServer } enabled := strings.ToLower(g.config.GetOrDefault("GRPC_ENABLE_REFLECTION", "false")) if enabled == "true" { //nolint:goconst // standard boolean string - reflection.Register(g.server) + reflection.Register(srv) } - return nil + g.server = srv + + return srv, nil +} + +// getServer returns the current server under the lock. Callers operate on the returned +// copy so a blocking call such as Serve or GracefulStop never holds srvMu. +func (g *grpcServer) getServer() *grpc.Server { + g.srvMu.Lock() + defer g.srvMu.Unlock() + + return g.server } func (g *grpcServer) Run(c *container.Container) { - if g.server == nil { - if err := g.createServer(); err != nil { - c.Logger.Fatalf("failed to create gRPC server: %v", err) - c.Metrics().IncrementCounter(context.Background(), "grpc_server_errors_total") + srv, err := g.ensureServer() + if err != nil { + c.Logger.Fatalf("failed to create gRPC server: %v", err) + c.Metrics().IncrementCounter(context.Background(), "grpc_server_errors_total") - return - } + return } if !isPortAvailable(g.port) { @@ -174,7 +203,7 @@ func (g *grpcServer) Run(c *container.Container) { c.Metrics().SetGauge("grpc_server_status", 1) c.Logger.Infof("gRPC server started successfully on %s", addr) - if err := g.server.Serve(listener); err != nil { + if err := srv.Serve(listener); err != nil { c.Logger.Errorf("error in starting gRPC server at %s: %s", addr, err) c.Metrics().IncrementCounter(context.Background(), "grpc_server_errors_total") c.Metrics().SetGauge("grpc_server_status", 0) @@ -188,35 +217,45 @@ func (g *grpcServer) Run(c *container.Container) { func (g *grpcServer) Shutdown(ctx context.Context) error { return ShutdownWithContext(ctx, func(_ context.Context) error { - if g.server != nil { - g.server.GracefulStop() - } + g.gracefulStop() return nil }, func() error { - if g.server != nil { - g.server.Stop() - } + g.forceStop() return nil }) } +// gracefulStop drains in-flight RPCs and returns when they are done. It is a no-op if no +// server was ever created, which is the case when no service has been registered. +func (g *grpcServer) gracefulStop() { + if srv := g.getServer(); srv != nil { + srv.GracefulStop() + } +} + +// forceStop closes the listener and cuts in-flight RPCs. Same no-op contract as gracefulStop. +func (g *grpcServer) forceStop() { + if srv := g.getServer(); srv != nil { + srv.Stop() + } +} + // RegisterService adds a gRPC service to the GoFr application. func (a *App) RegisterService(desc *grpc.ServiceDesc, impl any) { - if !a.grpcRegistered { - if err := a.grpcServer.createServer(); err != nil { - a.container.Logger.Errorf("failed to create gRPC server for service %s: %v", desc.ServiceName, err) - return - } + srv, err := a.grpcServer.ensureServer() + if err != nil { + a.container.Logger.Errorf("failed to create gRPC server for service %s: %v", desc.ServiceName, err) + return } a.container.Logger.Infof("registering gRPC Service: %s", desc.ServiceName) - a.grpcServer.server.RegisterService(desc, impl) + srv.RegisterService(desc, impl) a.container.Metrics().IncrementCounter(context.Background(), "grpc_services_registered_total") - err := injectContainer(impl, a.container) + err = injectContainer(impl, a.container) if err != nil { a.container.Logger.Fatalf("failed to inject container into gRPC service %s: %v", desc.ServiceName, err) } diff --git a/pkg/gofr/grpc_test.go b/pkg/gofr/grpc_test.go index 35fd73d0ad..f45f515c93 100644 --- a/pkg/gofr/grpc_test.go +++ b/pkg/gofr/grpc_test.go @@ -5,6 +5,7 @@ import ( "fmt" "net" "strconv" + "sync" "testing" "time" @@ -126,23 +127,28 @@ func TestGRPCServer_AddUnaryInterceptors(t *testing.T) { func TestGRPCServer_CreateServer(t *testing.T) { _, _, g := setupTestGRPCServer(t, 9999, false) - err := g.createServer() + srv, err := g.ensureServer() require.NoError(t, err) - assert.NotNil(t, g.server) + assert.NotNil(t, srv) + + // Second call must return the same server, not build another one. + again, err := g.ensureServer() + require.NoError(t, err) + assert.Same(t, srv, again) } func TestGRPCServer_RegisterService(t *testing.T) { _, _, g := setupTestGRPCServer(t, 9999, false) - err := g.createServer() + srv, err := g.ensureServer() require.NoError(t, err) healthServer := health.NewServer() desc := &grpc_health_v1.Health_ServiceDesc - g.server.RegisterService(desc, healthServer) + srv.RegisterService(desc, healthServer) - services := g.server.GetServiceInfo() + services := srv.GetServiceInfo() _, ok := services["grpc.health.v1.Health"] assert.True(t, ok, "health service should be registered") } @@ -165,8 +171,7 @@ func TestGRPC_ServerRun(t *testing.T) { } // Create the server first - err := g.createServer() - if err != nil { + if _, err := g.ensureServer(); err != nil { t.Fatalf("Failed to create server: %v", err) } @@ -228,8 +233,7 @@ func TestGRPC_ServerRun(t *testing.T) { } // Create the server first - err := g.createServer() - if err != nil { + if _, err := g.ensureServer(); err != nil { t.Fatalf("Failed to create server: %v", err) } @@ -375,6 +379,88 @@ func TestGRPC_Shutdown_BeforeStart_ContextCanceled(t *testing.T) { } } +// TestGRPC_ConcurrentRunAndShutdown guards against a regression of the data race the +// srvMu guard closes: ensureServer publishes g.server from the serve goroutine while +// Shutdown reads it on the caller's. Meaningful under -race; without the guard the +// detector reports both the field itself and the CAS inside grpc-go's Server.Stop. +func TestGRPC_ConcurrentRunAndShutdown(t *testing.T) { + c, _, g := setupTestGRPCServer(t, testutil.GetFreePort(t), false) + + var wg sync.WaitGroup + + wg.Add(1) + + go func() { + defer wg.Done() + + g.Run(c) + }() + + // No sleep: racing Shutdown against Run's publish of g.server is the point. + require.NoError(t, g.Shutdown(t.Context())) + + // That Shutdown may legitimately have observed no server yet and returned a no-op — + // Run is free to publish afterwards and keep serving. That lost-shutdown gap is + // nil-check-as-started, tracked in #3801, and is deliberately not what this test + // asserts; stop whatever Run ended up publishing so Run returns either way. + require.Eventually(t, func() bool { + srv := g.getServer() + if srv == nil { + return false + } + + srv.Stop() + + return true + }, time.Second, 10*time.Millisecond, "Run never published a server to stop") + + wg.Wait() +} + +// TestGRPC_ConcurrentEnsureServer guards the check-then-act half: with the check and the publish +// under one hold, concurrent callers all get the same server. Split across separate holds, two +// callers can both observe nil and both build one — the loser then serves a server no getServer +// returns and no Shutdown stops. The options length also pins the append inside ensureServer, +// which the same hold covers. +func TestGRPC_ConcurrentEnsureServer(t *testing.T) { + const callers = 8 + + _, _, g := setupTestGRPCServer(t, testutil.GetFreePort(t), false) + + var ( + wg sync.WaitGroup + servers = make([]*grpc.Server, callers) + errs = make([]error, callers) + start = make(chan struct{}) + ) + + for i := range callers { + wg.Add(1) + + go func() { + defer wg.Done() + + <-start + + servers[i], errs[i] = g.ensureServer() + }() + } + + close(start) + wg.Wait() + + for i := range callers { + require.NoError(t, errs[i]) + require.NotNil(t, servers[i]) + assert.Samef(t, servers[0], servers[i], "caller %d got a different server: two were created", i) + } + + // Exactly one build happened: the unary and stream interceptor options, appended once. + assert.Len(t, g.options, 2) + + servers[0].Stop() +} + func TestGRPC_ServerRun_WithInterceptorAndOptions(t *testing.T) { freePort := testutil.GetFreePort(t) c, _, g := setupTestGRPCServer(t, freePort, false) @@ -405,7 +491,7 @@ func TestGRPC_ServerRun_WithInterceptorAndOptions(t *testing.T) { app.AddGRPCUnaryInterceptors(interceptor1, interceptor2) // Create the server first - err := app.grpcServer.createServer() + srv, err := app.grpcServer.ensureServer() require.NoError(t, err) // Start the server in a goroutine @@ -423,7 +509,7 @@ func TestGRPC_ServerRun_WithInterceptorAndOptions(t *testing.T) { require.NoError(t, err) // Verify that the server was created with the interceptors and options - assert.NotNil(t, app.grpcServer.server) + assert.NotNil(t, srv) assert.Len(t, app.grpcServer.interceptors, 4) // 2 default + 2 test interceptors assert.Len(t, app.grpcServer.options, 4) // 2 test options + 2 default (interceptor) options } @@ -435,10 +521,10 @@ func TestApp_WithReflection(t *testing.T) { app.container = c app.grpcServer = g - err := app.grpcServer.createServer() + srv, err := app.grpcServer.ensureServer() require.NoError(t, err) - services := app.grpcServer.server.GetServiceInfo() + services := srv.GetServiceInfo() _, ok := services["grpc.reflection.v1alpha.ServerReflection"] assert.True(t, ok, "reflection service should be registered") } From 9a9ea856f205b612d0bca0008526606597e544dd Mon Sep 17 00:00:00 2001 From: Akshat Singhal <65562230+akshat-kumar-singhal@users.noreply.github.com> Date: Sat, 15 Aug 2026 14:14:52 +0530 Subject: [PATCH 04/20] chore(ci): build the website workflows on Node 24 instead of EOL Node 18 (#3870) (#3871) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Node 18 "Hydrogen" reached end-of-life on 2025-04-30 and receives no security patches, including for the bundled OpenSSL and undici. The prod and stage website workflows build and deploy gofr.dev — they run `yarn install --frozen-lockfile` and `yarn refresh-data` against the network while holding the GAR deployment key and `packages: write`. Targets 24.x rather than the 22.x the issue suggested: v22 has been in maintenance since 2025-10-21 while v24 is the Active LTS through 2028-04-30. Verified `yarn install --frozen-lockfile` is green on node:24-alpine against the website's current lockfile. Both files changed together so prod and stage don't drift. Co-authored-by: Aryan Mehrotra --- .github/workflows/website-prod.yml | 7 ++++++- .github/workflows/website-stage.yml | 5 ++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/.github/workflows/website-prod.yml b/.github/workflows/website-prod.yml index b9d67761cb..7ee3ff7bb7 100644 --- a/.github/workflows/website-prod.yml +++ b/.github/workflows/website-prod.yml @@ -46,10 +46,15 @@ jobs: # "Last updated" byline. fetch-depth: 0 + # 24.x is the active LTS (EOL 2028-04-30); 22.x entered maintenance + # on 2025-10-21. This governs only the two runner-side steps below, + # `yarn install` and `yarn refresh-data` — the `next build` itself + # runs inside the website image, which pins its own Node in + # gofr-dev/website's Dockerfile and is unaffected by this value. - name: Setup Node.js uses: actions/setup-node@v7 with: - node-version: 18.x + node-version: 24.x - name: Install website deps working-directory: ./website-src diff --git a/.github/workflows/website-stage.yml b/.github/workflows/website-stage.yml index 26408114c8..7c6edd916f 100644 --- a/.github/workflows/website-stage.yml +++ b/.github/workflows/website-stage.yml @@ -50,10 +50,13 @@ jobs: # "Last updated" byline. fetch-depth: 0 + # Same rationale as website-prod.yml — 24.x is the active LTS and + # only covers the runner-side `yarn install` / `yarn refresh-data` + # steps. Kept in lockstep with prod so the two don't drift. - name: Setup Node.js uses: actions/setup-node@v7 with: - node-version: 18.x + node-version: 24.x - name: Install website deps working-directory: ./website-src From 7c0fad473deccede780aacbc46d344717ec7d321 Mon Sep 17 00:00:00 2001 From: Akshat Singhal <65562230+akshat-kumar-singhal@users.noreply.github.com> Date: Mon, 17 Aug 2026 10:28:46 +0530 Subject: [PATCH 05/20] fix(examples): wait on a readiness signal instead of sleeping, and make using-migrations self-contained (#3818, #3816) (#3821) --- .github/workflows/go.yml | 10 +- .../grpc/grpc-streaming-server/main_test.go | 80 +++---- examples/grpc/grpc-unary-client/main_test.go | 11 +- examples/grpc/grpc-unary-server/main_test.go | 8 +- .../grpc-unary-server/server/health_test.go | 6 +- examples/http-server-using-redis/main_test.go | 2 +- examples/http-server/main_test.go | 7 +- examples/using-add-rest-handlers/main_test.go | 5 +- examples/using-cron-jobs/main_test.go | 19 +- examples/using-custom-metrics/main_test.go | 3 +- examples/using-file-bind/main_test.go | 3 +- examples/using-graphql/main_test.go | 21 +- examples/using-html-template/main_test.go | 7 +- .../using-http-auth-middleware/main_test.go | 6 +- examples/using-http-service/main_test.go | 3 +- examples/using-migrations/main_test.go | 95 +++++++- examples/using-publisher/main_test.go | 3 +- examples/using-s3-filestore/main_test.go | 34 ++- examples/using-subscriber/main_test.go | 16 +- examples/using-web-socket/main_test.go | 3 +- pkg/gofr/testutil/port.go | 66 +++++- pkg/gofr/testutil/port_test.go | 35 +++ pkg/gofr/testutil/wait.go | 205 ++++++++++++++++++ pkg/gofr/testutil/wait_test.go | 177 +++++++++++++++ 24 files changed, 678 insertions(+), 147 deletions(-) create mode 100644 pkg/gofr/testutil/wait.go create mode 100644 pkg/gofr/testutil/wait_test.go diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 11b82af95e..06905a63ea 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -122,13 +122,17 @@ jobs: docker logs minio || true exit 1 - # Run tests with automatic retry on failures - - name: Test with Retry Logic + - name: Test id: test uses: nick-fields/retry@v4 with: timeout_minutes: 5 # Maximum time for the tests to run - max_attempts: 2 # Retry up to 2 times if tests fail + # No retry. The example tests used to race their own server's boot — every one of them + # slept a fixed 100ms after `go main()` and hoped — and a retry turned that into a green + # tick. It equally hid a real regression that happened to pass on the second attempt. + # They now wait on a readiness signal (testutil.WaitFor*), so a failure here is a + # failure. The action is kept for its timeout_minutes. + max_attempts: 1 command: | # The retry action runs this block with a plain shell (no errexit), # unlike native `run:` steps that default to `bash -eo pipefail`. diff --git a/examples/grpc/grpc-streaming-server/main_test.go b/examples/grpc/grpc-streaming-server/main_test.go index 08bb3bcc52..9667210d0a 100644 --- a/examples/grpc/grpc-streaming-server/main_test.go +++ b/examples/grpc/grpc-streaming-server/main_test.go @@ -5,9 +5,7 @@ import ( "errors" "fmt" "io" - "net" "os" - "strconv" "strings" "testing" "time" @@ -17,65 +15,39 @@ import ( "google.golang.org/grpc/status" "gofr.dev/examples/grpc/grpc-streaming-server/server" + "gofr.dev/pkg/gofr/testutil" ) -// grpcHost is the address the example server under test listens on. -// -// The example's configs/.env pins GRPC_PORT=9000 for documentation purposes, -// but the test must not inherit it: `go test ./examples/...` runs packages -// concurrently and CI additionally binds host port 9000 (MinIO), so a fixed -// port makes the server fail to bind and the whole package abort with -// "gRPC port 9000 is blocked or unreachable". Reserve free ports instead — -// the same thing testutil.NewServerConfigs does for tests that have a -// *testing.T to attach the env cleanup to, which TestMain does not. +// grpcHost is the address the example's gRPC server listens on, assigned in TestMain. var grpcHost string func TestMain(m *testing.M) { os.Setenv("GOFR_TELEMETRY", "false") - grpcPort, err := reserveFreePort() + // Point the example at free ports rather than the fixed ones its configs/.env asks for. A + // fixed port is a hazard in a test: gofr.New() reports an already-taken port with Fatalf, + // which exits the process, so the whole package dies before a single test runs. configs/.env + // asks for GRPC_PORT 9000 — the port MinIO serves on, including in the CI job that runs these + // very tests. The system environment takes precedence over the config file, so what is set + // here is what the example uses. + configs, err := testutil.ReserveServerPorts() if err != nil { - fmt.Fprintf(os.Stderr, "could not reserve a free gRPC port: %v\n", err) + fmt.Fprintln(os.Stderr, err) os.Exit(1) } - httpPort, err := reserveFreePort() - if err != nil { - fmt.Fprintf(os.Stderr, "could not reserve a free HTTP port: %v\n", err) - os.Exit(1) - } - - metricsPort, err := reserveFreePort() - if err != nil { - fmt.Fprintf(os.Stderr, "could not reserve a free metrics port: %v\n", err) - os.Exit(1) - } - - os.Setenv("GRPC_PORT", strconv.Itoa(grpcPort)) - os.Setenv("HTTP_PORT", strconv.Itoa(httpPort)) - os.Setenv("METRICS_PORT", strconv.Itoa(metricsPort)) - - grpcHost = fmt.Sprintf("localhost:%d", grpcPort) + grpcHost = configs.GRPCHost go main() - time.Sleep(300 * time.Millisecond) // wait for server to boot - os.Exit(m.Run()) -} - -// reserveFreePort asks the kernel for a free port and releases it immediately -// so the server under test can bind it. -func reserveFreePort() (int, error) { - lc := net.ListenConfig{} - - listener, err := lc.Listen(context.Background(), "tcp", "localhost:0") - if err != nil { - return 0, err + // WaitForGRPCServerE rather than WaitForGRPCServer: TestMain gets a *testing.M and has no + // *testing.T for require to fail on, so it takes the error-returning variant of the same wait. + if err := testutil.WaitForGRPCServerE(grpcHost); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) } - port := listener.Addr().(*net.TCPAddr).Port - - return port, listener.Close() + os.Exit(m.Run()) } func TestServerStream(t *testing.T) { @@ -160,8 +132,9 @@ func TestBiDiStream(t *testing.T) { messages := []string{"msg1", "msg2", "msg3"} go func() { for _, msg := range messages { + // No pacing needed: the server handles this stream's messages one at a time and + // replies in order, so the responses stay ordered regardless of send timing. _ = stream.Send(&server.Request{Message: msg}) - time.Sleep(100 * time.Millisecond) } _ = stream.CloseSend() }() @@ -220,10 +193,9 @@ func TestServerStream_ContextCancellation(t *testing.T) { if !receivedFirst { receivedFirst = true - // Cancel context to trigger cancellation handling + // Cancel context to trigger cancellation handling. The next Recv then fails with + // Canceled off the client's own context — nothing to wait for the server on. cancel() - // Give server time to detect cancellation - time.Sleep(200 * time.Millisecond) } _ = resp // Use response to avoid unused variable @@ -267,10 +239,9 @@ func TestClientStream_ContextCancellation(t *testing.T) { t.Fatalf("Send failed: %v", err) } - // Cancel context + // Cancel context. The call below fails with Canceled off the client's own context, so there + // is nothing to wait for the server to notice. cancel() - // Give server time to detect cancellation - time.Sleep(200 * time.Millisecond) // Try to close and receive - should get cancellation error _, err = stream.CloseAndRecv() @@ -357,10 +328,9 @@ func TestBiDiStream_ContextCancellation(t *testing.T) { t.Errorf("Unexpected response: got %q, want %q", resp.GetMessage(), "Echo: test") } - // Cancel context + // Cancel context. The call below fails with Canceled off the client's own context, so there + // is nothing to wait for the server to notice. cancel() - // Give server time to detect cancellation - time.Sleep(200 * time.Millisecond) // Try to receive - should get cancellation error _, err = stream.Recv() diff --git a/examples/grpc/grpc-unary-client/main_test.go b/examples/grpc/grpc-unary-client/main_test.go index d63d64a99d..c65e847483 100644 --- a/examples/grpc/grpc-unary-client/main_test.go +++ b/examples/grpc/grpc-unary-client/main_test.go @@ -9,7 +9,6 @@ import ( "net/url" "os" "testing" - "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -56,15 +55,14 @@ func TestIntegration_UnaryClient(t *testing.T) { }() defer grpcServer.Stop() - // Give gRPC server time to start - time.Sleep(100 * time.Millisecond) + testutil.WaitForGRPCServer(t, configs.GRPCHost) // Set the gRPC server host for the client t.Setenv("GRPC_SERVER_HOST", configs.GRPCHost) // Start the HTTP server (unary client example) go main() - time.Sleep(100 * time.Millisecond) // Give HTTP server time to start + testutil.WaitForHTTPServer(t, configs.HTTPHost) // Test HTTP endpoints that use GoFr gRPC client internally tests := []struct { @@ -115,15 +113,14 @@ func TestIntegration_UnaryClient_Concurrent(t *testing.T) { }() defer grpcServer.Stop() - // Give gRPC server time to start - time.Sleep(100 * time.Millisecond) + testutil.WaitForGRPCServer(t, configs.GRPCHost) // Set the gRPC server host for the client t.Setenv("GRPC_SERVER_HOST", configs.GRPCHost) // Start the HTTP server (unary client example) go main() - time.Sleep(100 * time.Millisecond) // Give HTTP server time to start + testutil.WaitForHTTPServer(t, configs.HTTPHost) numClients := 5 done := make(chan bool, numClients) diff --git a/examples/grpc/grpc-unary-server/main_test.go b/examples/grpc/grpc-unary-server/main_test.go index ed8dc1b3e3..6ce058355f 100644 --- a/examples/grpc/grpc-unary-server/main_test.go +++ b/examples/grpc/grpc-unary-server/main_test.go @@ -29,7 +29,7 @@ func TestIntegration_UnaryServer(t *testing.T) { configs := testutil.NewServerConfigs(t) go main() - time.Sleep(100 * time.Millisecond) // Giving some time to start the server + testutil.WaitForGRPCServer(t, configs.GRPCHost) // Create gRPC client connection conn, err := grpc.Dial(configs.GRPCHost, grpc.WithTransportCredentials(insecure.NewCredentials())) @@ -64,7 +64,7 @@ func TestIntegration_UnaryServer_Concurrent(t *testing.T) { configs := testutil.NewServerConfigs(t) go main() - time.Sleep(100 * time.Millisecond) // Giving some time to start the server + testutil.WaitForGRPCServer(t, configs.GRPCHost) // Create gRPC client connection conn, err := grpc.Dial(configs.GRPCHost, grpc.WithTransportCredentials(insecure.NewCredentials())) @@ -97,7 +97,7 @@ func TestIntegration_UnaryServer_ErrorHandling(t *testing.T) { configs := testutil.NewServerConfigs(t) go main() - time.Sleep(100 * time.Millisecond) // Giving some time to start the server + testutil.WaitForGRPCServer(t, configs.GRPCHost) // Create gRPC client connection conn, err := grpc.Dial(configs.GRPCHost, grpc.WithTransportCredentials(insecure.NewCredentials())) @@ -164,7 +164,7 @@ func TestIntegration_UnaryServer_RateLimited(t *testing.T) { app.Run() }() - time.Sleep(200 * time.Millisecond) + testutil.WaitForGRPCServer(t, configs.GRPCHost) conn, err := grpc.NewClient(configs.GRPCHost, grpc.WithTransportCredentials(insecure.NewCredentials())) require.NoError(t, err, "Failed to connect to rate-limited server") diff --git a/examples/grpc/grpc-unary-server/server/health_test.go b/examples/grpc/grpc-unary-server/server/health_test.go index 6e1f9c76e8..9f98d40030 100644 --- a/examples/grpc/grpc-unary-server/server/health_test.go +++ b/examples/grpc/grpc-unary-server/server/health_test.go @@ -50,9 +50,11 @@ func TestGoFrHealthServer_Methods(t *testing.T) { ctx := createTestContext() t.Run("CheckMethodExists", func(t *testing.T) { - // Test that GoFr's Check method exists and accepts correct parameters + // The health server is a process-wide singleton, so this name must be one no other test + // registers — TestGoFrHealthServer_SetServingStatus registers "test-service", which made + // this sub-test fail on the second and later iterations of `go test -count=N`. req := &healthpb.HealthCheckRequest{ - Service: "test-service", + Service: "never-registered-service", } // Test GoFr's Check method signature - this will fail with "unknown service" which is expected diff --git a/examples/http-server-using-redis/main_test.go b/examples/http-server-using-redis/main_test.go index 0e145ffea5..6188449d40 100644 --- a/examples/http-server-using-redis/main_test.go +++ b/examples/http-server-using-redis/main_test.go @@ -31,7 +31,7 @@ func TestHTTPServerUsingRedis(t *testing.T) { configs := testutil.NewServerConfigs(t) go main() - time.Sleep(100 * time.Millisecond) // Giving some time to start the server + testutil.WaitForHTTPServer(t, configs.HTTPHost) tests := []struct { desc string diff --git a/examples/http-server/main_test.go b/examples/http-server/main_test.go index 5271be0626..796fa38d8b 100644 --- a/examples/http-server/main_test.go +++ b/examples/http-server/main_test.go @@ -25,7 +25,6 @@ import ( "strconv" "strings" "testing" - "time" "github.com/go-redis/redismock/v9" "github.com/stretchr/testify/assert" @@ -56,7 +55,7 @@ func TestIntegration_SimpleAPIServer(t *testing.T) { host := fmt.Sprintf("http://localhost:%d", httpPort) go main() - time.Sleep(100 * time.Millisecond) // Giving some time to start the server + testutil.WaitForHTTPServer(t, host) tests := []struct { desc string @@ -106,7 +105,7 @@ func TestIntegration_SimpleAPIServer_Errors(t *testing.T) { host := fmt.Sprintf("http://localhost:%d", httpPort) go main() - time.Sleep(100 * time.Millisecond) // Giving some time to start the server + testutil.WaitForHTTPServer(t, host) tests := []struct { desc string @@ -171,7 +170,7 @@ func TestIntegration_SimpleAPIServer_Health(t *testing.T) { host := fmt.Sprintf("http://localhost:%d", httpPort) go main() - time.Sleep(100 * time.Millisecond) // Giving some time to start the server + testutil.WaitForHTTPServer(t, host) tests := []struct { desc string diff --git a/examples/using-add-rest-handlers/main_test.go b/examples/using-add-rest-handlers/main_test.go index 6f0b9d8060..ee333434ad 100644 --- a/examples/using-add-rest-handlers/main_test.go +++ b/examples/using-add-rest-handlers/main_test.go @@ -5,7 +5,6 @@ import ( "net/http" "os" "testing" - "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -22,7 +21,9 @@ func TestIntegration_AddRESTHandlers(t *testing.T) { configs := testutil.NewServerConfigs(t) go main() - time.Sleep(100 * time.Millisecond) // Giving some time to start the server + // On a datastore that has not seen this example before, the migration runs at startup, which a + // fixed sleep would not reliably cover. + testutil.WaitForHTTPServer(t, configs.HTTPHost) tests := []struct { desc string diff --git a/examples/using-cron-jobs/main_test.go b/examples/using-cron-jobs/main_test.go index 9d5c469a70..0a9bc983c5 100644 --- a/examples/using-cron-jobs/main_test.go +++ b/examples/using-cron-jobs/main_test.go @@ -5,7 +5,7 @@ import ( "testing" "time" - "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "gofr.dev/pkg/gofr/testutil" ) @@ -19,16 +19,17 @@ func Test_UserPurgeCron(t *testing.T) { configs := testutil.NewServerConfigs(t) go main() - time.Sleep(1100 * time.Millisecond) - expected := 1 + // The job is scheduled every second. Waiting on the counter it increments covers both the + // server's startup and the first tick, neither of which fits a fixed duration: a sleep long + // enough for a loaded runner also lets the job fire more than once, which the old + // assert.Equal(1, n) would then fail on. + require.Eventually(t, func() bool { + mu.RLock() + defer mu.RUnlock() - var m int + return n > 0 + }, 30*time.Second, 100*time.Millisecond, "cron job did not run in time") - mu.Lock() - m = n - mu.Unlock() - - assert.Equal(t, expected, m) t.Logf("Metrics server running at: %s", configs.MetricsHost) } diff --git a/examples/using-custom-metrics/main_test.go b/examples/using-custom-metrics/main_test.go index 1c15be6ee1..3d1cff0970 100644 --- a/examples/using-custom-metrics/main_test.go +++ b/examples/using-custom-metrics/main_test.go @@ -6,7 +6,6 @@ import ( "net/http" "os" "testing" - "time" "github.com/stretchr/testify/assert" @@ -22,7 +21,7 @@ func TestIntegration(t *testing.T) { configs := testutil.NewServerConfigs(t) go main() - time.Sleep(100 * time.Millisecond) // Giving some time to start the server + testutil.WaitForHTTPServer(t, configs.HTTPHost) c := http.Client{} diff --git a/examples/using-file-bind/main_test.go b/examples/using-file-bind/main_test.go index 94c323a289..2cae567038 100644 --- a/examples/using-file-bind/main_test.go +++ b/examples/using-file-bind/main_test.go @@ -7,7 +7,6 @@ import ( "net/http" "os" "testing" - "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -24,7 +23,7 @@ func TestMain_BindError(t *testing.T) { configs := testutil.NewServerConfigs(t) go main() - time.Sleep(100 * time.Millisecond) + testutil.WaitForHTTPServer(t, configs.HTTPHost) c := http.Client{} diff --git a/examples/using-graphql/main_test.go b/examples/using-graphql/main_test.go index be39117476..937c062562 100644 --- a/examples/using-graphql/main_test.go +++ b/examples/using-graphql/main_test.go @@ -8,7 +8,6 @@ import ( "net/http" "strconv" "testing" - "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -16,24 +15,6 @@ import ( "gofr.dev/pkg/gofr/testutil" ) -func waitForReady(t *testing.T, host string) { - t.Helper() - client := &http.Client{Timeout: 1 * time.Second} - deadline := time.Now().Add(10 * time.Second) - - for time.Now().Before(deadline) { - resp, err := client.Get(host + "/.well-known/alive") - if err == nil { - resp.Body.Close() - if resp.StatusCode == http.StatusOK { - return - } - } - time.Sleep(100 * time.Millisecond) - } - t.Fatalf("Server at %s not ready after 10s", host) -} - // newTestApp creates a GoFr application configured for integration testing. func newTestApp(t *testing.T) (*gofr.App, string) { t.Helper() @@ -93,7 +74,7 @@ func TestIntegration_GraphQL(t *testing.T) { go app.Run() - waitForReady(t, host) + testutil.WaitForHTTPServer(t, host) t.Run("hello query", func(t *testing.T) { query := `{"query": "{ hello }"}` diff --git a/examples/using-html-template/main_test.go b/examples/using-html-template/main_test.go index 893e47c078..313168cc66 100644 --- a/examples/using-html-template/main_test.go +++ b/examples/using-html-template/main_test.go @@ -7,7 +7,6 @@ import ( "os" "strings" "testing" - "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -25,7 +24,7 @@ func Test_ListHandler(t *testing.T) { c := &http.Client{} go main() - time.Sleep(100 * time.Millisecond) + testutil.WaitForHTTPServer(t, configs.HTTPHost) // Make a GET request to the /list endpoint req, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, @@ -62,7 +61,7 @@ func Test_IndexHTML(t *testing.T) { c := &http.Client{} go main() - time.Sleep(100 * time.Millisecond) // Allow server to start + testutil.WaitForHTTPServer(t, configs.HTTPHost) // Request root endpoint req, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, @@ -91,7 +90,7 @@ func Test_404HTML(t *testing.T) { c := &http.Client{} go main() - time.Sleep(100 * time.Millisecond) // Allow server to start + testutil.WaitForHTTPServer(t, configs.HTTPHost) // Request non-existent endpoint req, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, diff --git a/examples/using-http-auth-middleware/main_test.go b/examples/using-http-auth-middleware/main_test.go index 37b0583de0..184b79a8f7 100644 --- a/examples/using-http-auth-middleware/main_test.go +++ b/examples/using-http-auth-middleware/main_test.go @@ -14,8 +14,7 @@ func Test_setupAPIKeyAuthFailed(t *testing.T) { // Run main() in a goroutine to avoid blocking go main() - // Allow time for server to start - time.Sleep(100 * time.Millisecond) + testutil.WaitForHTTPServer(t, serverConfigs.HTTPHost) client := &http.Client{Timeout: 200 * time.Millisecond} @@ -41,8 +40,7 @@ func Test_setupAPIKeyAuthSuccess(t *testing.T) { // Run main() in a goroutine to avoid blocking go main() - // Allow time for server to start - time.Sleep(100 * time.Millisecond) + testutil.WaitForHTTPServer(t, serverConfigs.HTTPHost) client := &http.Client{Timeout: 200 * time.Millisecond} diff --git a/examples/using-http-service/main_test.go b/examples/using-http-service/main_test.go index eecf64175a..65636ec46f 100644 --- a/examples/using-http-service/main_test.go +++ b/examples/using-http-service/main_test.go @@ -9,7 +9,6 @@ import ( "net/http/httptest" "os" "testing" - "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -36,7 +35,7 @@ func Test_main(t *testing.T) { c := &http.Client{} go main() - time.Sleep(100 * time.Millisecond) + testutil.WaitForHTTPServer(t, configs.HTTPHost) testCases := []struct { desc string diff --git a/examples/using-migrations/main_test.go b/examples/using-migrations/main_test.go index ac471972b0..82b6048bf9 100644 --- a/examples/using-migrations/main_test.go +++ b/examples/using-migrations/main_test.go @@ -2,27 +2,118 @@ package main import ( "bytes" + "context" + "database/sql" + "fmt" + "net" "net/http" "os" + "strconv" "testing" - "time" + _ "github.com/go-sql-driver/mysql" + "github.com/redis/go-redis/v9" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "gofr.dev/pkg/gofr/config" + "gofr.dev/pkg/gofr/logging" "gofr.dev/pkg/gofr/testutil" ) +const ( + configFolder = "./configs" + + // testDatabase is the MySQL database this test owns. The example's own configs point at the + // shared `test` database, which every other example migrates into as well - migration + // bookkeeping there is keyed by version only, so the highest version any example has run wins + // and the others get skipped. Migrating into a database this test owns keeps the run + // independent of the other examples and of what previous runs left behind. + // + // It is dropped and re-created at the start of every run rather than dropped at the end, so a + // failing run leaves its data behind to be inspected. + testDatabase = "test_using_migrations" + + // testRedisDB is the logical Redis database this test owns, and is the same idea as + // testDatabase: the migrator uses the container's client, so pointing the example at a + // database the test flushes first means no previous run's bookkeeping can make this one skip + // its migrations. Namespacing rather than deleting a known list of keys - the list would go + // stale, silently, the first time a Redis migration writes a key nobody thought to add to it. + testRedisDB = 1 +) + func TestMain(m *testing.M) { os.Setenv("GOFR_TELEMETRY", "false") + + c := config.NewEnvFile(configFolder, logging.NewLogger(logging.ERROR)) + + if err := setupSQL(c); err != nil { + fmt.Fprintf(os.Stderr, "could not set up the test database: %v\n", err) + os.Exit(1) + } + + if err := setupRedis(c); err != nil { + fmt.Fprintf(os.Stderr, "could not set up the test redis database: %v\n", err) + os.Exit(1) + } + m.Run() } +// setupSQL recreates the database this test migrates into and points the example at it. +func setupSQL(c config.Config) error { + // Connecting without a database, as the one this test uses is about to be created. + dsn := fmt.Sprintf("%s:%s@tcp(%s)/", c.Get("DB_USER"), c.Get("DB_PASSWORD"), + net.JoinHostPort(c.Get("DB_HOST"), c.Get("DB_PORT"))) + + db, err := sql.Open("mysql", dsn) + if err != nil { + return err + } + + defer db.Close() + + for _, query := range []string{"DROP DATABASE IF EXISTS " + testDatabase, "CREATE DATABASE " + testDatabase} { + if _, err := db.Exec(query); err != nil { + return err + } + } + + // The config files are read again by gofr.New(), which does not override what is already set. + return os.Setenv("DB_NAME", testDatabase) +} + +// setupRedis empties the logical Redis database this test migrates into and points the example at +// it. GoFr reads REDIS_DB (pkg/gofr/datasource/redis/config.go), and the Redis migrator runs on +// that same client, so the selection covers the migration bookkeeping as well as the data. +// +// FLUSHDB, not a list of the keys the example writes: a list has to be kept in step with +// migrations/, and the next Redis migration that writes a key nobody remembered to add would +// silently make this example non-repeatable again. The cost is that this empties logical database +// testRedisDB in full — safe against the throwaway container the test job runs, worth knowing +// before pointing REDIS_HOST/REDIS_PORT at a Redis that holds anything else. +func setupRedis(c config.Config) error { + client := redis.NewClient(&redis.Options{ + Addr: net.JoinHostPort(c.Get("REDIS_HOST"), c.Get("REDIS_PORT")), + DB: testRedisDB, + }) + + defer client.Close() + + if err := client.FlushDB(context.Background()).Err(); err != nil { + return err + } + + // The config files are read again by gofr.New(), which does not override what is already set. + return os.Setenv("REDIS_DB", strconv.Itoa(testRedisDB)) +} + func TestExampleMigration(t *testing.T) { configs := testutil.NewServerConfigs(t) go main() - time.Sleep(100 * time.Millisecond) // Giving some time to start the server + // The migrations run at startup, which a fixed sleep would not reliably cover. + testutil.WaitForHTTPServer(t, configs.HTTPHost) tests := []struct { desc string diff --git a/examples/using-publisher/main_test.go b/examples/using-publisher/main_test.go index 65d4d38cd1..ca5e15e627 100644 --- a/examples/using-publisher/main_test.go +++ b/examples/using-publisher/main_test.go @@ -10,7 +10,6 @@ import ( "net/http/httptest" "os" "testing" - "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -33,7 +32,7 @@ func TestExamplePublisherError(t *testing.T) { host := fmt.Sprint("http://localhost:", configs.HTTPPort) go main() - time.Sleep(200 * time.Millisecond) + testutil.WaitForHTTPServer(t, host) testCases := []struct { desc string diff --git a/examples/using-s3-filestore/main_test.go b/examples/using-s3-filestore/main_test.go index f083a56ac5..cf454438a4 100644 --- a/examples/using-s3-filestore/main_test.go +++ b/examples/using-s3-filestore/main_test.go @@ -63,7 +63,7 @@ func TestS3FileStore_RoundTrip(t *testing.T) { t.Setenv("GOFR_TELEMETRY", "false") go main() - time.Sleep(200 * time.Millisecond) // give the server time to start + waitForHTTPServer(t, configs.HTTPHost) // Defect 1: an object larger than one HTTP transport chunk must round-trip // byte-for-byte. The pre-fix Read filled only the first few KB of the buffer. @@ -202,6 +202,38 @@ func download(t *testing.T, host, name string) string { return env.Data.Content } +// waitForHTTPServer blocks until the server at host answers its liveness probe, +// and fails the test if it never does. It replaces a fixed sleep after +// `go main()`: a sleep long enough for a loaded CI runner is wasted on every +// other run, and one short enough to be cheap races the listener. +// +// pkg/gofr/testutil has the same helper, and this is a copy of it — deliberately. +// This directory is a separate module whose go.mod requires the released +// gofr.dev v1.57.0 with no `replace`, and that release's testutil has no +// WaitForHTTPServer. Calling the shared one would compile only inside the +// workspace and break `GOWORK=off go build ./...` here. Drop this in favor of +// testutil.WaitForHTTPServer once this module's gofr.dev requirement moves to a +// release that carries it. +func waitForHTTPServer(t *testing.T, host string) { + t.Helper() + + require.Eventually(t, func() bool { + req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, host+"/.well-known/alive", http.NoBody) + if err != nil { + return false + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return false + } + + defer resp.Body.Close() + + return resp.StatusCode == http.StatusOK + }, 30*time.Second, 100*time.Millisecond, "HTTP server at %s did not start in time", host) +} + // newSDKClient builds a raw S3 client used only for test setup (bucket // creation), independent of the code under test. func newSDKClient(t *testing.T) *s3.Client { diff --git a/examples/using-subscriber/main_test.go b/examples/using-subscriber/main_test.go index 7dd7de1584..c856bd9db0 100644 --- a/examples/using-subscriber/main_test.go +++ b/examples/using-subscriber/main_test.go @@ -4,9 +4,7 @@ import ( "context" "errors" "os" - "strings" "testing" - "time" "gofr.dev/pkg/gofr" "gofr.dev/pkg/gofr/container" @@ -21,16 +19,14 @@ func TestMain(m *testing.M) { } func TestMainInitialization(t *testing.T) { - log := testutil.StdoutOutputForFunc(func() { - go main() + // The example registers no routes, only subscribers, so GoFr starts no HTTP server here and + // there is no liveness endpoint to poll. The Kafka connection log is the startup signal, and + // waiting for it is the assertion: the helper fails the test if it never arrives. + testutil.NewServerConfigs(t) - time.Sleep(200 * time.Millisecond) + testutil.WaitForStdoutContains(t, "connected to 1 Kafka brokers", func() { + go main() }) - - expectedLog := "connected to 1 Kafka brokers" - if !strings.Contains(log, expectedLog) { - t.Errorf("Expected log to contain %q, but got: %s", expectedLog, log) - } } type errorRequest struct{} diff --git a/examples/using-web-socket/main_test.go b/examples/using-web-socket/main_test.go index 367223fa5d..9d4e46a34b 100644 --- a/examples/using-web-socket/main_test.go +++ b/examples/using-web-socket/main_test.go @@ -4,7 +4,6 @@ import ( "fmt" "os" "testing" - "time" "github.com/gorilla/websocket" "github.com/stretchr/testify/assert" @@ -22,7 +21,7 @@ func Test_WebSocket_Success(t *testing.T) { wsURL := fmt.Sprintf("ws://localhost:%d/ws", configs.HTTPPort) go main() - time.Sleep(100 * time.Millisecond) + testutil.WaitForHTTPServer(t, configs.HTTPHost) testMessage := "Hello! GoFr" dialer := &websocket.Dialer{} diff --git a/pkg/gofr/testutil/port.go b/pkg/gofr/testutil/port.go index f03108d2f4..efa139a240 100644 --- a/pkg/gofr/testutil/port.go +++ b/pkg/gofr/testutil/port.go @@ -1,28 +1,46 @@ package testutil import ( + "context" "fmt" "net" + "os" "strconv" "testing" "github.com/stretchr/testify/require" ) +// Environment variables the framework reads its listen ports from. +const ( + httpPortEnv = "HTTP_PORT" + metricsPortEnv = "METRICS_PORT" + grpcPortEnv = "GRPC_PORT" +) + // GetFreePort asks the kernel for a free open port that is ready to use for tests. func GetFreePort(t *testing.T) int { t.Helper() - lc := net.ListenConfig{} - listener, err := lc.Listen(t.Context(), "tcp", "localhost:0") + port, err := reserveFreePort(t.Context()) require.NoError(t, err, "Failed to get a free port.") - port := listener.Addr().(*net.TCPAddr).Port + return port +} - err = listener.Close() - require.NoError(t, err, "Failed to get a free port.") +// reserveFreePort asks the kernel for a free open port. It is the plumbing behind GetFreePort and +// ReserveServerPorts, which differ only in how they report a failure. +func reserveFreePort(ctx context.Context) (int, error) { + lc := net.ListenConfig{} - return port + listener, err := lc.Listen(ctx, "tcp", "localhost:0") + if err != nil { + return 0, err + } + + port := listener.Addr().(*net.TCPAddr).Port + + return port, listener.Close() } // ServiceConfigs holds the configuration details for different server components. @@ -55,10 +73,40 @@ func NewServerConfigs(t *testing.T) *ServiceConfigs { metricsPort := GetFreePort(t) grpcPort := GetFreePort(t) - t.Setenv("HTTP_PORT", strconv.Itoa(httpPort)) - t.Setenv("METRICS_PORT", strconv.Itoa(metricsPort)) - t.Setenv("GRPC_PORT", strconv.Itoa(grpcPort)) + t.Setenv(httpPortEnv, strconv.Itoa(httpPort)) + t.Setenv(metricsPortEnv, strconv.Itoa(metricsPort)) + t.Setenv(grpcPortEnv, strconv.Itoa(grpcPort)) + + return newServiceConfigs(httpPort, metricsPort, grpcPort) +} + +// ReserveServerPorts is NewServerConfigs for a caller that has no *testing.T to fail on — a +// TestMain, which gets only a *testing.M, is the usual one. It reports a failure by returning an +// error, and sets the ports with os.Setenv rather than t.Setenv, there being no test scope to +// restore them at the end of. Prefer NewServerConfigs wherever a *testing.T is in hand. +func ReserveServerPorts() (*ServiceConfigs, error) { + // A slice rather than a map: the order the ports are reserved in, and the name that appears in + // an error, are then the same on every run. + names := []string{httpPortEnv, metricsPortEnv, grpcPortEnv} + ports := make([]int, len(names)) + + for i, name := range names { + port, err := reserveFreePort(context.Background()) + if err != nil { + return nil, fmt.Errorf("failed to reserve a free %s: %w", name, err) + } + + ports[i] = port + + if err := os.Setenv(name, strconv.Itoa(port)); err != nil { + return nil, fmt.Errorf("failed to set %s: %w", name, err) + } + } + + return newServiceConfigs(ports[0], ports[1], ports[2]), nil +} +func newServiceConfigs(httpPort, metricsPort, grpcPort int) *ServiceConfigs { return &ServiceConfigs{ HTTPPort: httpPort, HTTPHost: fmt.Sprintf("http://localhost:%d", httpPort), diff --git a/pkg/gofr/testutil/port_test.go b/pkg/gofr/testutil/port_test.go index a786efa249..9d8a390ddf 100644 --- a/pkg/gofr/testutil/port_test.go +++ b/pkg/gofr/testutil/port_test.go @@ -125,3 +125,38 @@ func TestServiceConfigs_GetOrDefault(t *testing.T) { }) } } + +func TestReserveServerPorts(t *testing.T) { + // os.Setenv rather than t.Setenv, so restore what this test displaces. + for _, key := range []string{"HTTP_PORT", "METRICS_PORT", "GRPC_PORT"} { + t.Setenv(key, os.Getenv(key)) + } + + configs, err := ReserveServerPorts() + require.NoError(t, err, "ReserveServerPorts should not fail") + + tests := []struct { + desc string + envKey string + port int + host string + expected string + }{ + {"http", "HTTP_PORT", configs.HTTPPort, configs.HTTPHost, "http://localhost:"}, + {"metrics", "METRICS_PORT", configs.MetricsPort, configs.MetricsHost, "http://localhost:"}, + {"grpc", "GRPC_PORT", configs.GRPCPort, configs.GRPCHost, "localhost:"}, + } + + for i, tc := range tests { + assert.NotZero(t, tc.port, "TEST[%d], Failed.\n%s", i, tc.desc) + assert.Equal(t, strconv.Itoa(tc.port), os.Getenv(tc.envKey), "TEST[%d], Failed.\n%s", i, tc.desc) + assert.Equal(t, tc.expected+strconv.Itoa(tc.port), tc.host, "TEST[%d], Failed.\n%s", i, tc.desc) + + // The reserved port must actually be bindable. + lc := net.ListenConfig{} + + listener, err := lc.Listen(t.Context(), "tcp", fmt.Sprintf("localhost:%d", tc.port)) + require.NoError(t, err, "TEST[%d], Failed.\n%s", i, tc.desc) + require.NoError(t, listener.Close(), "TEST[%d], Failed.\n%s", i, tc.desc) + } +} diff --git a/pkg/gofr/testutil/wait.go b/pkg/gofr/testutil/wait.go new file mode 100644 index 0000000000..966c5f82d7 --- /dev/null +++ b/pkg/gofr/testutil/wait.go @@ -0,0 +1,205 @@ +package testutil + +import ( + "context" + "errors" + "fmt" + "net/http" + "os" + "strings" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + "google.golang.org/grpc/connectivity" + "google.golang.org/grpc/credentials/insecure" +) + +const ( + // alivePath duplicates service.AlivePath. It cannot be imported here: pkg/gofr/service's own + // tests import this package, so testutil -> service would be an import cycle. + alivePath = "/.well-known/alive" + + serverStartTimeout = 30 * time.Second + serverPollInterval = 100 * time.Millisecond + + // stdoutReadBuffer is the chunk size the stdout capture reads with; a log line is far shorter. + stdoutReadBuffer = 4096 +) + +// errServerNotReady is what the error-returning wait variants wrap, so a caller can tell a +// readiness timeout apart from a failure to set the wait up at all. +var errServerNotReady = errors.New("server did not become ready in time") + +// WaitForHTTPServer blocks until the server at host answers its liveness probe, and fails the +// test if it never does. Use it instead of sleeping a fixed duration after `go main()`: a sleep +// long enough for a loaded CI runner is wasted on every other run, and one short enough to be +// cheap races the listener. +func WaitForHTTPServer(t *testing.T, host string) { + t.Helper() + + require.Eventually(t, func() bool { + req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, host+alivePath, http.NoBody) + if err != nil { + return false + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return false + } + + defer resp.Body.Close() + + return resp.StatusCode == http.StatusOK + }, serverStartTimeout, serverPollInterval, "HTTP server at %s did not start in time", host) +} + +// WaitForGRPCServer blocks until a gRPC connection to addr reaches the ready state, and fails the +// test if it never does. gRPC examples that serve no HTTP port have no liveness endpoint to poll, +// and a bare TCP dial is not enough — the kernel accepts connections from the moment the listener +// exists, so it succeeds before the server is serving. Reaching ready means the HTTP/2 handshake +// completed, which only happens once it is. +func WaitForGRPCServer(t *testing.T, addr string) { + t.Helper() + + require.NoError(t, waitForGRPC(t.Context(), addr)) +} + +// WaitForGRPCServerE is WaitForGRPCServer for a caller that has no *testing.T to fail on — a +// TestMain, which gets only a *testing.M, is the usual one. It reports a failure by returning an +// error. The E suffix is the Foo/FooE convention for exactly this pair; prefer WaitForGRPCServer +// wherever a *testing.T is in hand. +func WaitForGRPCServerE(addr string) error { + return waitForGRPC(context.Background(), addr) +} + +// waitForGRPC blocks until a gRPC connection to addr reaches the ready state. It is the plumbing +// behind WaitForGRPCServer and WaitForGRPCServerE, which differ only in how they report a failure. +func waitForGRPC(ctx context.Context, addr string) error { + conn, err := grpc.NewClient(addr, grpc.WithTransportCredentials(insecure.NewCredentials())) + if err != nil { + return fmt.Errorf("failed to create a gRPC client for %s: %w", addr, err) + } + + defer conn.Close() + + ctx, cancel := context.WithTimeout(ctx, serverStartTimeout) + defer cancel() + + start := time.Now() + + conn.Connect() + + for state := conn.GetState(); state != connectivity.Ready; state = conn.GetState() { + if !conn.WaitForStateChange(ctx, state) { + // The elapsed time rather than serverStartTimeout: the caller's own context can + // expire first, and a message naming the constant would then overstate the wait. + return fmt.Errorf("%w: gRPC server at %s did not start in %s", + errServerNotReady, addr, time.Since(start).Round(time.Millisecond)) + } + } + + return nil +} + +// WaitForStdoutContains runs f with os.Stdout captured and blocks until the captured output +// contains substr, returning everything captured up to that point. It fails the test if substr +// never appears. +// +// Use it for an app whose only startup signal is a log line. An app that registers no routes - +// a subscriber-only one, say - leaves App.httpRegistered false, so GoFr starts no HTTP server +// and WaitForHTTPServer would wait for a port that never opens. StdoutOutputForFunc cannot serve +// here either: it reads the pipe only once f has returned, so f would have to sleep to give the +// app time to log, which is the race this package exists to remove. +func WaitForStdoutContains(t *testing.T, substr string, f func()) string { + t.Helper() + + r, w, err := os.Pipe() + require.NoError(t, err, "failed to create a pipe to capture stdout") + + old := os.Stdout + os.Stdout = w + + var ( + mu sync.Mutex + captured strings.Builder + ) + + found := captureUntil(r, substr, &mu, &captured) + + f() + + var timedOut bool + + select { + case <-found: + case <-time.After(serverStartTimeout): + timedOut = true + } + + // Restoring before the assertion below keeps a failure message on the real stdout. Closing the + // writer ends the reader; the app under test goes on writing to a closed pipe, as it does with + // StdoutOutputForFunc too. + os.Stdout = old + _ = w.Close() + + mu.Lock() + out := captured.String() + mu.Unlock() + + if timedOut { + require.Fail(t, "expected output never appeared on stdout", + "waited %s for %q, captured:\n%s", serverStartTimeout, substr, out) + } + + return out +} + +// captureUntil drains r into out and closes the returned channel as soon as out holds substr. It +// reads concurrently rather than in one ReadAll at the end, so a caller can act on the signal +// while the process that writes it is still running. +func captureUntil(r *os.File, substr string, mu *sync.Mutex, out *strings.Builder) <-chan struct{} { + found := make(chan struct{}) + + go func() { + var ( + once sync.Once + // scanned is how much of out has already been searched. Rescanning the whole builder + // on every read would be quadratic in the captured output — free when the signal + // arrives in the first read or two, but not on the timeout path against a chatty app. + scanned int + ) + + buf := make([]byte, stdoutReadBuffer) + + for { + n, err := r.Read(buf) + + if n > 0 { + mu.Lock() + out.Write(buf[:n]) + + // Carry over len(substr)-1 bytes so a match straddling a read boundary is found. + from := max(scanned-len(substr)+1, 0) + // strings.Builder.String does not copy, so this stays linear overall. + s := out.String() + hit := strings.Contains(s[from:], substr) + scanned = len(s) + mu.Unlock() + + if hit { + once.Do(func() { close(found) }) + } + } + + if err != nil { + return + } + } + }() + + return found +} diff --git a/pkg/gofr/testutil/wait_test.go b/pkg/gofr/testutil/wait_test.go new file mode 100644 index 0000000000..7c76fa5d31 --- /dev/null +++ b/pkg/gofr/testutil/wait_test.go @@ -0,0 +1,177 @@ +package testutil + +import ( + "context" + "fmt" + "net" + "net/http" + "os" + "testing" + "time" + + "github.com/stretchr/testify/require" + "google.golang.org/grpc" +) + +// startHTTPServerAfter starts an HTTP server serving the liveness path on addr once delay has +// elapsed, mimicking an example whose startup is slower than the caller's first request. +func startHTTPServerAfter(t *testing.T, delay time.Duration) (host string) { + t.Helper() + + port := GetFreePort(t) + + mux := http.NewServeMux() + mux.HandleFunc(alivePath, func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }) + + srv := &http.Server{Addr: fmt.Sprintf("localhost:%d", port), Handler: mux, ReadHeaderTimeout: time.Second} + + go func() { + time.Sleep(delay) + + _ = srv.ListenAndServe() + }() + + t.Cleanup(func() { + _ = srv.Close() + }) + + return fmt.Sprintf("http://localhost:%d", port) +} + +// startGRPCServerAfter starts a bare gRPC server on addr once delay has elapsed. +func startGRPCServerAfter(t *testing.T, delay time.Duration) (addr string) { + t.Helper() + + port := GetFreePort(t) + addr = fmt.Sprintf("localhost:%d", port) + srv := grpc.NewServer() + + go func() { + time.Sleep(delay) + + listener, err := (&net.ListenConfig{}).Listen(context.Background(), "tcp", addr) + if err != nil { + return + } + + _ = srv.Serve(listener) + }() + + t.Cleanup(srv.Stop) + + return addr +} + +func TestWaitForHTTPServer(t *testing.T) { + tests := []struct { + desc string + startDelay time.Duration + }{ + {"server already listening", 0}, + {"server starts after the first probe", 300 * time.Millisecond}, + } + + for i, tc := range tests { + host := startHTTPServerAfter(t, tc.startDelay) + + WaitForHTTPServer(t, host) + + req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, host+alivePath, http.NoBody) + require.NoError(t, err, "TEST[%d], Failed.\n%s", i, tc.desc) + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err, "TEST[%d], Failed.\n%s", i, tc.desc) + + resp.Body.Close() + + require.Equal(t, http.StatusOK, resp.StatusCode, "TEST[%d], Failed.\n%s", i, tc.desc) + } +} + +func TestWaitForStdoutContains(t *testing.T) { + tests := []struct { + desc string + writeWait time.Duration + // writes are logged in order, with a pause between them so each lands in its own read. + // Splitting the awaited substring across two of them puts it across a read boundary, which + // the incremental scan has to carry over rather than miss. + writes []string + }{ + {"line already written when f returns", 0, []string{"starting up\nready to serve\n"}}, + {"line written well after f returns", 300 * time.Millisecond, []string{"starting up\nready to serve\n"}}, + {"substring straddles a read boundary", 0, []string{"starting up\nrea", "dy to serve\n"}}, + } + + for i, tc := range tests { + out := WaitForStdoutContains(t, "ready", func() { + go func() { + time.Sleep(tc.writeWait) + + for _, w := range tc.writes { + fmt.Fprint(os.Stdout, w) + // Long enough for the reader to drain what has been written so far. + time.Sleep(100 * time.Millisecond) + } + }() + }) + + require.Contains(t, out, "ready to serve", "TEST[%d], Failed.\n%s", i, tc.desc) + // Everything written before the awaited line is returned as well. + require.Contains(t, out, "starting up", "TEST[%d], Failed.\n%s", i, tc.desc) + } +} + +func TestWaitForGRPCServer(t *testing.T) { + tests := []struct { + desc string + startDelay time.Duration + }{ + {"server already listening", 0}, + {"server starts after the first probe", 300 * time.Millisecond}, + } + + for i, tc := range tests { + addr := startGRPCServerAfter(t, tc.startDelay) + + WaitForGRPCServer(t, addr) + + conn, err := (&net.Dialer{}).DialContext(t.Context(), "tcp", addr) + require.NoError(t, err, "TEST[%d], Failed.\n%s", i, tc.desc) + + require.NoError(t, conn.Close(), "TEST[%d], Failed.\n%s", i, tc.desc) + } +} + +func TestWaitForGRPCServerE(t *testing.T) { + tests := []struct { + desc string + startDelay time.Duration + }{ + {"server already listening", 0}, + {"server starts after the first probe", 300 * time.Millisecond}, + } + + for i, tc := range tests { + addr := startGRPCServerAfter(t, tc.startDelay) + + require.NoError(t, WaitForGRPCServerE(addr), "TEST[%d], Failed.\n%s", i, tc.desc) + } +} + +// TestWaitForGRPC_Timeout covers the failure path the exported wrappers share. It exercises +// waitForGRPC rather than WaitForGRPCServer or WaitForGRPCServerE so the deadline can be a short one: +// the wrappers hardcode serverStartTimeout, and a test is not going to wait 30s to see it. +func TestWaitForGRPC_Timeout(t *testing.T) { + // A port reserved and released: nothing is listening, so the wait cannot succeed. + addr := fmt.Sprintf("localhost:%d", GetFreePort(t)) + + ctx, cancel := context.WithTimeout(t.Context(), 200*time.Millisecond) + defer cancel() + + err := waitForGRPC(ctx, addr) + + require.ErrorIs(t, err, errServerNotReady) + require.ErrorContains(t, err, addr, "the error should name the server that did not come up") +} From f1e5c1a7bc9854c88e61954f56276e02ca2ddd92 Mon Sep 17 00:00:00 2001 From: Aryan Mehrotra Date: Mon, 17 Aug 2026 15:56:33 +0530 Subject: [PATCH 06/20] feat(ai): add Embed capability to the LLM (#3757) --- docs/advanced-guide/llm/page.md | 74 ++++++++- examples/using-ai/main.go | 35 ++++ examples/using-ai/main_test.go | 29 +++- pkg/gofr/ai/compat_test.go | 106 ++++++++++++ pkg/gofr/ai/embed_test.go | 87 ++++++++++ pkg/gofr/ai/errors.go | 2 + pkg/gofr/ai/instrument.go | 1 + pkg/gofr/ai/llm.go | 34 ++++ pkg/gofr/ai/llm/client.go | 175 +++++++++++++++++++- pkg/gofr/ai/llm/client_concurrency_test.go | 184 +++++++++++++++++++++ pkg/gofr/ai/llm/client_embed_test.go | 152 +++++++++++++++++ pkg/gofr/ai/llm/stream.go | 42 ++++- pkg/gofr/ai/llm/stream_test.go | 2 +- pkg/gofr/ai/llm/wire.go | 24 +++ pkg/gofr/ai/llm_test.go | 3 + pkg/gofr/ai/mock_ai.go | 114 ++++++++++++- pkg/gofr/ai/model.go | 40 +++++ pkg/gofr/container/container.go | 9 + 18 files changed, 1087 insertions(+), 26 deletions(-) create mode 100644 pkg/gofr/ai/compat_test.go create mode 100644 pkg/gofr/ai/embed_test.go create mode 100644 pkg/gofr/ai/llm/client_concurrency_test.go create mode 100644 pkg/gofr/ai/llm/client_embed_test.go diff --git a/docs/advanced-guide/llm/page.md b/docs/advanced-guide/llm/page.md index 8c5e82237f..6ef9c73582 100644 --- a/docs/advanced-guide/llm/page.md +++ b/docs/advanced-guide/llm/page.md @@ -100,6 +100,11 @@ named one) and its metrics carry the model's own `provider`/`model` labels. - `Stream(ctx, messages, ...opts)` — an incremental token stream (see below). - `Tools()` — the service's own handlers as agent-callable tools (see [Building AI Agents](/docs/advanced-guide/mcp)). +Capabilities beyond these are reached by asserting an optional interface on `ctx.LLM()` — currently +`ai.EmbeddingLLM` for [embeddings](#embeddings). They are kept off the `ai.LLM` interface on purpose: +`ai.LLM` is frozen so that your own test fakes and wrappers keep compiling when GoFr adds a +capability. + Options are applied per call: `ai.WithTemperature(0.2)`, `ai.WithMaxTokens(512)`, `ai.WithTools(...)`. ```go @@ -152,6 +157,68 @@ if tc, ok := stream.(ai.ToolCallStreamer); ok { } ``` +## Embeddings + +Embeddings turn text into vectors — the primitive behind semantic search and agent memory: embed text +on write, embed a query on read, and rank stored vectors by similarity. They ride the same tracing and +token metrics as `Chat`. + +Embed is reached by asserting `ai.EmbeddingLLM` on the LLM. The assertion always succeeds on the LLM +GoFr hands your handler, so treat a failure as a programming error rather than a missing provider: + +```go +e, ok := c.LLM("embed").(ai.EmbeddingLLM) +if !ok { + return nil, errors.New("embeddings unavailable") +} + +resp, err := e.Embed(c, []string{"the quick brown fox", "a fast auburn fox"}) +if err != nil { + return nil, err +} + +vectors := resp.Embeddings // one []float32 per input, in order; resp.Usage carries the prompt tokens +``` + +`vectors[i]` is the embedding of `input[i]`. That is guaranteed, not assumed: the client places each +vector by the `index` the provider reports rather than by its position in the response, so an +OpenAI-compatible backend that returns the array out of order cannot silently pair an input with +someone else's vector. A response that cannot be mapped — a vector count that disagrees with the +inputs sent, an index outside them, or one claimed twice — is returned as an error rather than +half-mapped, so `len(resp.Embeddings) == len(input)` holds whenever `err` is nil. + +Embeddings are usually a *different* model from your chat model, so register one with a name and +select it per call: + +```go +app.AddLLM(&llm.Client{Provider: llm.OpenAI, Model: "gpt-4o-mini"}) // chat (default) +app.AddLLM(&llm.Client{Provider: llm.OpenAI, Model: "text-embedding-3-small"}, gofr.WithName("embed")) // embeddings +``` + +Not every model can embed — a chat-only model has none. That is reported by `Embed` itself, not by a +failed assertion: it returns `ai.ErrEmbedNotSupported` (mirroring how `Stream` returns +`ai.ErrStreamNotSupported`), so a misconfiguration fails clearly instead of panicking. If no model is +registered at all, `Embed` returns `ai.ErrLLMNotConfigured`. + +## Limiting concurrency + +By default the client sends every request straight to the provider. When many handlers call the model +at once and the provider serializes internally (a single local model, or a tight rate-limit tier), +that burst piles up and tail latency spikes. Set `MaxConcurrentRequests` to cap in-flight calls — +excess `Chat`/`Embed`/`Stream` calls block (honoring their context deadline) until a slot frees: + +```go +app.AddLLM(&llm.Client{ + Provider: llm.Ollama, + Model: "llama3.2:1b", + MaxConcurrentRequests: 4, // at most 4 requests in flight; 0 (the default) is unlimited +}) +``` + +This is backpressure, not parallelism — it keeps a burst from overwhelming the provider, but the +provider's own throughput (and, for a hosted API, your rate-limit tier) still governs how fast +requests complete. + ## Built-in Observability Every call is observable the same way a normal GoFr request is, joined by the correlation ID. @@ -173,9 +240,10 @@ metric labels. ### Traces -A span per call (`llm.chat` / `llm.generate` / `llm.stream`) carrying provider, model and token -attributes (`llm.tokens.prompt/completion/total/cached/reasoning`) — a child of the request span and -the parent of the provider's HTTP span. +A span per call (`llm.chat` / `llm.generate` / `llm.stream` / `llm.embed`) carrying provider, model +and token attributes (`llm.tokens.prompt/completion/total/cached/reasoning`) — a child of the request +span and the parent of the provider's HTTP span. On `llm.embed` only the prompt tokens are non-zero, +since embeddings bill input alone. ### Logs diff --git a/examples/using-ai/main.go b/examples/using-ai/main.go index 02727dd6ce..517eafbce1 100644 --- a/examples/using-ai/main.go +++ b/examples/using-ai/main.go @@ -3,9 +3,12 @@ // - response.Stream stream tokens to the client (POST /stream) // - app.EnableMCP expose the GET /inventory/{sku} handler as an agent tool // - ctx.LLM().Tools() drive an agent loop over that tool (POST /agent) +// - ai.EmbeddingLLM turn text into vectors (POST /embed) package main import ( + "errors" + "gofr.dev/pkg/gofr" "gofr.dev/pkg/gofr/ai" "gofr.dev/pkg/gofr/ai/llm" @@ -14,6 +17,8 @@ import ( const maxTurns = 5 +var errEmbeddingsUnavailable = errors.New("embeddings unavailable") + func main() { app := gofr.New() @@ -29,6 +34,7 @@ func main() { app.POST("/ask", ask) // one-shot completion app.POST("/stream", stream) // streamed completion app.POST("/agent", agent) // agent loop using the service's own tools + app.POST("/embed", embed) // text -> vectors app.Run() } @@ -66,6 +72,35 @@ func stream(c *gofr.Context) (any, error) { return response.Stream{Source: s}, nil } +// embed turns text into vectors — the primitive behind semantic search and agent memory. Embeddings +// are usually a different model from the chat one, so a real service registers a second LLM with +// gofr.WithName("embed") and selects it per call; this example has a single model, so it asserts on +// the default. Whether the model can actually embed is reported by Embed itself +// (ai.ErrEmbedNotSupported), not by the assertion — the assertion only checks the LLM carries the +// capability at all. +func embed(c *gofr.Context) (any, error) { + var in struct { + Inputs []string `json:"inputs"` + } + + if err := c.Bind(&in); err != nil { + return nil, err + } + + model, ok := c.LLM().(ai.EmbeddingLLM) + if !ok { + return nil, errEmbeddingsUnavailable + } + + resp, err := model.Embed(c, in.Inputs) + if err != nil { + return nil, err + } + + // One vector per input, in input order: resp.Embeddings[i] belongs to in.Inputs[i]. + return resp.Embeddings, nil +} + func agent(c *gofr.Context) (any, error) { var in struct { Task string `json:"task"` diff --git a/examples/using-ai/main_test.go b/examples/using-ai/main_test.go index a1fb2c6119..9bf120d695 100644 --- a/examples/using-ai/main_test.go +++ b/examples/using-ai/main_test.go @@ -24,9 +24,9 @@ func TestMain(m *testing.M) { os.Exit(m.Run()) } -// mockProvider is an in-test OpenAI-compatible server: it answers /models (health), returns a -// streamed reply when the request asks for one, drives one agent turn (tool call then final answer) -// when tools are present, and otherwise returns a plain completion. +// mockProvider is an in-test OpenAI-compatible server: it answers /models (health) and /embeddings, +// returns a streamed reply when the request asks for one, drives one agent turn (tool call then +// final answer) when tools are present, and otherwise returns a plain completion. func mockProvider(t *testing.T) *httptest.Server { t.Helper() @@ -36,6 +36,11 @@ func mockProvider(t *testing.T) *httptest.Server { return } + if r.URL.Path == "/embeddings" { + _, _ = io.WriteString(w, embeddingsJSON()) + return + } + body, _ := io.ReadAll(r.Body) switch { @@ -72,6 +77,17 @@ func chatJSON(content string) string { `"usage":{"prompt_tokens":5,"completion_tokens":3}}`, content) } +// embeddingsJSON answers with the two vectors deliberately out of order — index 1 before index 0. +// The wire contract permits any order, which is exactly what the "index" field is for, so this is a +// response a real OpenAI-compatible backend is allowed to send. The handler must still hand each +// input the vector computed from it, which is what the assertion below pins. +func embeddingsJSON() string { + return `{"model":"mock","data":[` + + `{"embedding":[0.3,0.4],"index":1},` + + `{"embedding":[0.1,0.2],"index":0}],` + + `"usage":{"prompt_tokens":5}}` +} + func toolCallJSON() string { return `{"model":"mock","choices":[{"message":{"role":"assistant","tool_calls":` + `[{"id":"c1","type":"function","function":{"name":"get_inventory_sku",` + @@ -124,6 +140,13 @@ func TestIntegration_UsingAI(t *testing.T) { assert.Contains(t, post(t, base+"/agent", `{"task":"stock?"}`), "final answer") }) + t.Run("embed returns one vector per input, in input order", func(t *testing.T) { + out := post(t, base+"/embed", `{"inputs":["the quick brown fox","a fast auburn fox"]}`) + // The provider answered index 1 first. Pairing by array position would return these + // swapped, and nothing downstream would notice — a wrong vector is still a valid vector. + assert.Contains(t, out, `[[0.1,0.2],[0.3,0.4]]`) + }) + t.Run("no goroutine leak under repeated streaming", func(t *testing.T) { assertNoStreamLeak(t, base) }) diff --git a/pkg/gofr/ai/compat_test.go b/pkg/gofr/ai/compat_test.go new file mode 100644 index 0000000000..0df6be146d --- /dev/null +++ b/pkg/gofr/ai/compat_test.go @@ -0,0 +1,106 @@ +package ai + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "gofr.dev/pkg/gofr/datasource" +) + +// LLM documents a compatibility freeze: capabilities arrive as new optional interfaces asserted by +// the caller, never as new methods on LLM, so that hand-written fakes and third-party wrappers keep +// compiling across minor versions. The freeze is only worth the words if something enforces it. +// +// legacyFake is the fake a user writes against LLM. It deliberately implements the interface and +// nothing more — no Embed, no future capability method. If a capability is ever added to LLM +// directly, this file stops compiling, which is the point: the break surfaces here, in this repo, +// instead of in a user's build after they upgrade. +type legacyFake struct{} + +func (*legacyFake) Chat(context.Context, []Message, ...Option) (*Response, error) { + return &Response{Content: "canned"}, nil +} + +func (*legacyFake) Generate(context.Context, string, ...Option) (*Response, error) { + return &Response{Content: "canned"}, nil +} + +func (*legacyFake) Stream(context.Context, []Message, ...Option) (Streamer, error) { + return nil, ErrStreamNotSupported +} + +func (*legacyFake) Tools() Tools { return emptyTools{} } +func (*legacyFake) HealthCheck(context.Context) datasource.Health { return datasource.Health{} } +func (*legacyFake) Name() string { return "legacy-fake" } + +// The assignment a user actually writes in their tests. +var _ LLM = (*legacyFake)(nil) + +// The capability is reachable from the LLM GoFr hands to a handler, without being declared on LLM. +var _ EmbeddingLLM = (*llm)(nil) + +func TestLLM_FreezeHoldsForHandWrittenFakes(t *testing.T) { + // Compiling is the assertion; this body just proves the fake is usable as an LLM. + var l LLM = &legacyFake{} + + resp, err := l.Chat(t.Context(), []Message{{Role: RoleUser, Content: "hi"}}) + require.NoError(t, err) + assert.Equal(t, "canned", resp.Content) + + _, err = l.Stream(t.Context(), nil) + require.ErrorIs(t, err, ErrStreamNotSupported) +} + +func TestLLM_EmbeddingCapabilityAlwaysAssertable(t *testing.T) { + // Chat-only provider: the assertion must still succeed, so a handler never has to distinguish + // "capability missing from this build" from "provider does not support it". + e, ok := NewLLM(&fakeModel{}, Deps{}).(EmbeddingLLM) + require.True(t, ok, "ctx.LLM() must always be assertable to EmbeddingLLM") + + _, err := e.Embed(t.Context(), []string{"x"}) + assert.ErrorIs(t, err, ErrEmbedNotSupported, "a chat-only provider reports the error, not a failed assertion") +} + +// nilEmbedder is a third-party provider that violates the contract mildly: it reports success but +// returns no response. The Chat path already tolerates this (record skips a nil response), so Embed +// must too rather than panicking the handler. +type nilEmbedder struct{ *fakeModel } + +func (nilEmbedder) Embed(context.Context, []string, ...Option) (*EmbeddingResponse, error) { + return nil, nil //nolint:nilnil // deliberately models a misbehaving third-party provider +} + +func TestLLM_Embed_NilResponseFromProviderDoesNotPanic(t *testing.T) { + m := &fakeMetrics{} + e := embedderOf(t, nilEmbedder{fakeModel: &fakeModel{}}, Deps{Metrics: m}) + + assert.NotPanics(t, func() { + resp, err := e.Embed(t.Context(), []string{"x"}) + require.NoError(t, err) + assert.Nil(t, resp) + }) + + // The call is still recorded, with zero usage rather than a crash. + require.Len(t, m.counters, 1) + assert.Equal(t, statusSuccess, labelValue(m.counters[0].labels, "status")) +} + +// Chat's tolerance of the same violation, pinned alongside Embed's so the two paths cannot drift. +type nilChatModel struct{ *fakeModel } + +func (nilChatModel) Chat(context.Context, []Message, ...Option) (*Response, error) { + return nil, nil //nolint:nilnil // deliberately models a misbehaving third-party provider +} + +func TestLLM_Chat_NilResponseFromProviderDoesNotPanic(t *testing.T) { + l := NewLLM(nilChatModel{fakeModel: &fakeModel{}}, Deps{}) + + assert.NotPanics(t, func() { + resp, err := l.Chat(t.Context(), []Message{{Role: RoleUser, Content: "hi"}}) + require.NoError(t, err) + assert.Nil(t, resp) + }) +} diff --git a/pkg/gofr/ai/embed_test.go b/pkg/gofr/ai/embed_test.go new file mode 100644 index 0000000000..c4d29b4cdb --- /dev/null +++ b/pkg/gofr/ai/embed_test.go @@ -0,0 +1,87 @@ +package ai + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +var errProviderDown = errors.New("provider down") + +// embedModel adds the optional Embedder capability to fakeModel. +type embedModel struct { + *fakeModel + embResp *EmbeddingResponse + embErr error + gotInput []string +} + +func (e *embedModel) Embed(_ context.Context, input []string, _ ...Option) (*EmbeddingResponse, error) { + e.gotInput = input + return e.embResp, e.embErr +} + +// embedderOf builds the LLM GoFr hands to a handler and asserts EmbeddingLLM on it, exactly as a +// handler does. The assertion is part of what is under test: the LLM returned by ctx.LLM() must +// always satisfy EmbeddingLLM, so the capability is reachable without the caller knowing whether +// the configured provider happens to support it. +func embedderOf(t *testing.T, m Model, d Deps) EmbeddingLLM { + t.Helper() + + e, ok := NewLLM(m, d).(EmbeddingLLM) + require.True(t, ok, "the LLM returned by GoFr must always implement EmbeddingLLM") + + return e +} + +// Embed forwards the input to the model, returns its vectors, and records exactly one successful +// call. +func TestLLM_Embed_Delegates(t *testing.T) { + m := &fakeMetrics{} + em := &embedModel{ + fakeModel: &fakeModel{}, + embResp: &EmbeddingResponse{ + Embeddings: [][]float32{{0.1, 0.2}, {0.3, 0.4}}, + Usage: Usage{PromptTokens: 6}, + Model: "embed-model", + }, + } + e := embedderOf(t, em, Deps{Metrics: m}) + + resp, err := e.Embed(t.Context(), []string{"hello", "world"}) + require.NoError(t, err) + assert.Equal(t, [][]float32{{0.1, 0.2}, {0.3, 0.4}}, resp.Embeddings) + assert.Equal(t, "embed-model", resp.Model) + assert.Equal(t, []string{"hello", "world"}, em.gotInput) + + require.Len(t, m.counters, 1) + assert.Equal(t, statusSuccess, labelValue(m.counters[0].labels, "status")) + assert.Equal(t, opEmbed, labelValue(m.counters[0].labels, "operation")) +} + +// A chat-only model (no Embedder) returns ErrEmbedNotSupported gracefully — no panic — recorded as +// an error call, mirroring the Stream unsupported path. +func TestLLM_Embed_UnsupportedRecordsError(t *testing.T) { + m := &fakeMetrics{} + e := embedderOf(t, &fakeModel{}, Deps{Metrics: m}) + + _, err := e.Embed(t.Context(), []string{"x"}) + require.ErrorIs(t, err, ErrEmbedNotSupported) + require.Len(t, m.counters, 1) + assert.Equal(t, statusError, labelValue(m.counters[0].labels, "status")) +} + +// An error from the underlying model propagates and is recorded as an error call. +func TestLLM_Embed_ModelErrorRecordsError(t *testing.T) { + m := &fakeMetrics{} + em := &embedModel{fakeModel: &fakeModel{}, embErr: errProviderDown} + e := embedderOf(t, em, Deps{Metrics: m}) + + _, err := e.Embed(t.Context(), []string{"x"}) + require.ErrorIs(t, err, errProviderDown) + require.Len(t, m.counters, 1) + assert.Equal(t, statusError, labelValue(m.counters[0].labels, "status")) +} diff --git a/pkg/gofr/ai/errors.go b/pkg/gofr/ai/errors.go index 9dc76e2abb..5da23ac7fb 100644 --- a/pkg/gofr/ai/errors.go +++ b/pkg/gofr/ai/errors.go @@ -5,6 +5,8 @@ import "errors" var ( // ErrStreamNotSupported is returned by Stream when the underlying model cannot stream. ErrStreamNotSupported = errors.New("streaming is not supported by this model") + // ErrEmbedNotSupported is returned by Embed when the underlying model cannot embed. + ErrEmbedNotSupported = errors.New("embeddings are not supported by this model") // ErrToolNotFound is returned by Tools.Call for an unknown tool name. ErrToolNotFound = errors.New("tool not found") // ErrLLMNotConfigured is returned by every call on the LLM from ctx.LLM(name) when no model is diff --git a/pkg/gofr/ai/instrument.go b/pkg/gofr/ai/instrument.go index 534b75a6da..467457d140 100644 --- a/pkg/gofr/ai/instrument.go +++ b/pkg/gofr/ai/instrument.go @@ -23,6 +23,7 @@ const ( opGenerate = "generate" opChat = "chat" opStream = "stream" + opEmbed = "embed" tokenTypePrompt = "prompt" tokenTypeCompletion = "completion" diff --git a/pkg/gofr/ai/llm.go b/pkg/gofr/ai/llm.go index 8363c3a127..9af18a7075 100644 --- a/pkg/gofr/ai/llm.go +++ b/pkg/gofr/ai/llm.go @@ -64,6 +64,40 @@ func (l *llm) Stream(ctx context.Context, messages []Message, opts ...Option) (S return &instrumentedStream{Streamer: s, rec: rec}, nil } +// Embed turns text into embedding vectors, recording the call with the same observability as Chat +// and Stream. It returns ErrEmbedNotSupported when the underlying provider does not implement the +// Embedder capability (e.g. a chat-only model), mirroring how Stream reports ErrStreamNotSupported. +// +// Reached by asserting EmbeddingLLM on ctx.LLM() rather than through the LLM interface itself, so +// that adding embeddings keeps LLM's compatibility freeze intact. +func (l *llm) Embed(ctx context.Context, input []string, opts ...Option) (*EmbeddingResponse, error) { + rec := StartCall(ctx, &CallInfo{Deps: l.deps, Provider: l.providerLabel, Model: l.modelLabel, Op: opEmbed}) + + e, ok := l.model.(Embedder) + if !ok { + rec.Finish(Usage{}, ErrEmbedNotSupported) + return nil, ErrEmbedNotSupported + } + + resp, err := e.Embed(rec.Context(), input, opts...) + if err != nil { + rec.Finish(Usage{}, err) + return nil, err + } + + // A provider may report success yet hand back no response. The Chat path already tolerates that + // (record skips a nil response); read usage defensively here so a third-party Embedder cannot + // panic the handler. + var usage Usage + if resp != nil { + usage = resp.Usage + } + + rec.Finish(usage, nil) + + return resp, nil +} + func (l *llm) Tools() Tools { if l.deps.Tools != nil { return l.deps.Tools diff --git a/pkg/gofr/ai/llm/client.go b/pkg/gofr/ai/llm/client.go index 7743b997ad..e421df3c8d 100644 --- a/pkg/gofr/ai/llm/client.go +++ b/pkg/gofr/ai/llm/client.go @@ -38,6 +38,7 @@ const ( maxErrBodyLen = 512 chatCompletionsPath = "chat/completions" + embeddingsPath = "embeddings" modelsPath = "models" headerContentType = "Content-Type" @@ -50,6 +51,7 @@ const ( var ( _ ai.Model = (*Client)(nil) _ ai.StreamingModel = (*Client)(nil) + _ ai.Embedder = (*Client)(nil) _ ai.Descriptor = (*Client)(nil) ) @@ -66,6 +68,12 @@ type Client struct { // providers whose usage object deviates from the standard shape. The zero value uses the built-in // mapping (OpenAI/Groq/DeepSeek), so the popular providers need no configuration. UsageFields UsageFields + // MaxConcurrentRequests caps the number of in-flight requests to the provider. When the cap is + // reached, further Chat/Embed/Stream calls block until a slot frees (or their context is done) — + // backpressure so a burst of concurrent agent calls cannot pile onto a provider that serializes + // internally and turn every request's tail latency pathological. HealthCheck is never limited. + // Zero (the default) means unlimited. + MaxConcurrentRequests int apiKey string baseURL string @@ -73,12 +81,30 @@ type Client struct { logger service.Logger metrics service.Metrics config config.Config + sem chan struct{} // in-flight limiter; nil when MaxConcurrentRequests <= 0 healthMu sync.Mutex healthExpiry time.Time healthCache datasource.Health } +// acquire blocks until an in-flight slot is free (or ctx is done) when a concurrency cap is set, and +// returns a release that frees the slot. Both are no-ops when no cap is configured. The returned +// release is idempotent, so a streamer may call it on both exhaustion and Close. +func (c *Client) acquire(ctx context.Context) (release func(), err error) { + if c.sem == nil { + return func() {}, nil + } + + select { + case c.sem <- struct{}{}: + var once sync.Once + return func() { once.Do(func() { <-c.sem }) }, nil + case <-ctx.Done(): + return nil, ctx.Err() + } +} + // UseLogger wires the framework logger into the underlying HTTP service. func (c *Client) UseLogger(logger any) { if l, ok := logger.(service.Logger); ok { @@ -115,6 +141,10 @@ func (c *Client) Connect() { return } + if c.MaxConcurrentRequests > 0 { + c.sem = make(chan struct{}, c.MaxConcurrentRequests) + } + c.svc = service.NewHTTPService(c.baseURL, c.logger, c.metrics, &service.ConnectionPoolConfig{ MaxIdleConns: defaultMaxIdleConns, @@ -176,24 +206,20 @@ func (c *Client) Chat(ctx context.Context, messages []ai.Message, opts ...ai.Opt return nil, errNotConnected } - body, err := c.buildRequest(messages, opts, false) + release, err := c.acquire(ctx) if err != nil { return nil, err } + defer release() - resp, err := c.post(ctx, chatCompletionsPath, body) + body, err := c.buildRequest(messages, opts, false) if err != nil { return nil, err } - defer drain(resp) - data, err := io.ReadAll(resp.Body) + data, err := c.postJSON(ctx, chatCompletionsPath, body) if err != nil { - return nil, fmt.Errorf("%w: %w", errRequestFailed, err) - } - - if !isSuccess(resp.StatusCode) { - return nil, c.statusError(resp.StatusCode, data) + return nil, err } var cr chatResponse @@ -217,8 +243,117 @@ func (c *Client) Chat(ctx context.Context, messages []ai.Message, opts ...ai.Opt return out, nil } +// Embed satisfies ai.Embedder: it posts the input texts to the OpenAI-compatible /embeddings +// endpoint and returns one vector per input, in the same order. It rides the same instrumented +// HTTP service, retry and error handling as Chat. +// +// Options are accepted for signature parity with Chat and Stream but none currently apply, so they +// are deliberately ignored rather than silently half-honored: the options GoFr defines today +// (WithTemperature, WithMaxTokens, WithTools) are all completion parameters with no meaning for an +// embeddings request. The request body is therefore fixed at {model, input}. +// +// The ceiling that implies: the provider-side embedding parameters — notably `dimensions`, which +// drives Matryoshka truncation on text-embedding-3-*, and `encoding_format` — cannot be set through +// this client. Supporting them needs embedding-specific options plus the matching fields on +// embeddingsRequest; tracked in gofr-dev/gofr#3803. +func (c *Client) Embed(ctx context.Context, input []string, _ ...ai.Option) (*ai.EmbeddingResponse, error) { + if c.svc == nil { + return nil, errNotConnected + } + + release, err := c.acquire(ctx) + if err != nil { + return nil, err + } + defer release() + + body, err := json.Marshal(embeddingsRequest{Model: c.Model, Input: input}) + if err != nil { + return nil, fmt.Errorf("%w: %w", errEncodeRequest, err) + } + + data, err := c.postJSON(ctx, embeddingsPath, body) + if err != nil { + return nil, err + } + + var er embeddingsResponse + if err = json.Unmarshal(data, &er); err != nil { + return nil, fmt.Errorf("%w: %w", errDecodeResponse, err) + } + + if er.Error != nil { + return nil, fmt.Errorf("%w: %s", errProvider, er.Error.Message) + } + + embeddings, err := placeEmbeddings(er.Data, len(input)) + if err != nil { + return nil, err + } + + return &ai.EmbeddingResponse{ + Model: er.Model, + Usage: mapUsage(&c.UsageFields, er.Usage), + Embeddings: embeddings, + }, nil +} + +// placeEmbeddings maps the response data array onto one vector per input, honoring each entry's +// "index" rather than its array position. The distinction only shows up on a provider that returns +// the array out of order — which the index field exists to allow — and there, positional mapping +// hands every input someone else's vector with nothing to signal it. That is a bad failure for +// embeddings: a wrong vector is still a valid vector, so semantic search and agent memory degrade +// silently instead of erroring. +// +// A missing index falls back to the entry's position, so providers that omit the field keep working. +// An index outside the input range, or one claimed twice, means the response cannot be mapped at all, +// and is reported instead of guessed at. +// +// Everything is validated against inputs — the number of texts sent — rather than against the length +// of the response, so a short response is caught for what it is. The embeddings endpoint returns one +// entry per input and reports failures for the whole request, so a count that disagrees is a broken +// response, and mapping it anyway would hand the caller a slice shorter than the inputs it was built +// from: an out-of-range panic for a caller indexing by input, or a silently skipped document for one +// ranging over the result. +func placeEmbeddings(data []embeddingDatum, inputs int) ([][]float32, error) { + if len(data) != inputs { + return nil, fmt.Errorf("%w: provider returned %d embeddings for %d inputs", + errDecodeResponse, len(data), inputs) + } + + out := make([][]float32, inputs) + seen := make([]bool, inputs) + + for i := range data { + pos := i + if data[i].Index != nil { + pos = *data[i].Index + } + + if pos < 0 || pos >= inputs { + return nil, fmt.Errorf("%w: embedding index %d outside the %d inputs sent", + errDecodeResponse, pos, inputs) + } + + if seen[pos] { + return nil, fmt.Errorf("%w: embedding index %d returned more than once", errDecodeResponse, pos) + } + + seen[pos] = true + out[pos] = data[i].Embedding + } + + return out, nil +} + // HealthCheck reports provider reachability, caching the result for a short TTL. The API key is // never included in the returned details. +// +// The lock is deliberately held across the probe rather than released around it: that makes the +// probe single-flight, so a burst of concurrent health checks on a cold cache costs the provider one +// request instead of one per caller. The cost is that concurrent callers wait for the in-flight probe +// (bounded by healthProbeTimeout) instead of racing their own — the right trade for a call that is +// polled by /.well-known/health rather than served on a request path. func (c *Client) HealthCheck(ctx context.Context) datasource.Health { c.healthMu.Lock() defer c.healthMu.Unlock() @@ -334,6 +469,28 @@ func (c *Client) post(ctx context.Context, path string, body []byte) (*http.Resp return nil, errRequestFailed } +// postJSON sends a JSON body to path and returns the raw success-response bytes, mapping transport, +// body-read and non-2xx status failures to errors. Decoding the success body is left to the caller, +// so Chat and Embed share it; Stream reads its body incrementally and does not. +func (c *Client) postJSON(ctx context.Context, path string, body []byte) ([]byte, error) { + resp, err := c.post(ctx, path, body) + if err != nil { + return nil, err + } + defer drain(resp) + + data, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("%w: %w", errRequestFailed, err) + } + + if !isSuccess(resp.StatusCode) { + return nil, c.statusError(resp.StatusCode, data) + } + + return data, nil +} + func shouldRetry(ctx context.Context, resp *http.Response, err error) bool { if ctx.Err() != nil { return false diff --git a/pkg/gofr/ai/llm/client_concurrency_test.go b/pkg/gofr/ai/llm/client_concurrency_test.go new file mode 100644 index 0000000000..f11354242a --- /dev/null +++ b/pkg/gofr/ai/llm/client_concurrency_test.go @@ -0,0 +1,184 @@ +package llm + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "gofr.dev/pkg/gofr/ai" +) + +// limitedClient builds a connected client with a concurrency cap (testClient connects before the +// field could be set, so construct directly). +func limitedClient(baseURL string, limit int) *Client { + c := &Client{Provider: OpenAI, Model: "test-model", BaseURL: baseURL, MaxConcurrentRequests: limit} + c.UseLogger(nopLogger{}) + c.UseMetrics(nil) + c.Connect() + + return c +} + +// countingChatServer records the peak number of simultaneously in-flight requests. +func countingChatServer(inflight, peak *atomic.Int32) *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + n := inflight.Add(1) + + for { + p := peak.Load() + if n <= p || peak.CompareAndSwap(p, n) { + break + } + } + + time.Sleep(40 * time.Millisecond) // hold the slot so concurrent calls overlap + inflight.Add(-1) + + w.WriteHeader(http.StatusOK) + _, _ = io.WriteString(w, `{"choices":[{"message":{"content":"ok"}}]}`) + })) +} + +func TestClient_MaxConcurrentRequests_CapsInFlight(t *testing.T) { + const ( + limit = 2 + callers = 8 + ) + + var inflight, peak atomic.Int32 + + srv := countingChatServer(&inflight, &peak) + defer srv.Close() + + c := limitedClient(srv.URL, limit) + + var wg sync.WaitGroup + for range callers { + wg.Add(1) + + go func() { + defer wg.Done() + + _, _ = c.Chat(t.Context(), []ai.Message{{Role: ai.RoleUser, Content: "hi"}}) + }() + } + + wg.Wait() + + assert.LessOrEqual(t, int(peak.Load()), limit, "in-flight requests must never exceed the limit") + assert.EqualValues(t, limit, peak.Load(), "concurrent load should saturate the limit") +} + +func TestClient_MaxConcurrentRequests_Unlimited(t *testing.T) { + const callers = 6 + + var inflight, peak atomic.Int32 + + srv := countingChatServer(&inflight, &peak) + defer srv.Close() + + c := limitedClient(srv.URL, 0) // 0 = no cap + + var wg sync.WaitGroup + for range callers { + wg.Add(1) + + go func() { + defer wg.Done() + + _, _ = c.Chat(t.Context(), []ai.Message{{Role: ai.RoleUser, Content: "hi"}}) + }() + } + + wg.Wait() + + assert.Greater(t, int(peak.Load()), 1, "with no cap, requests should run concurrently") +} + +// A caller waiting for a slot honors context cancellation instead of blocking forever. +func TestClient_MaxConcurrentRequests_ContextCancelledWhileWaiting(t *testing.T) { + var inflight, peak atomic.Int32 + + srv := countingChatServer(&inflight, &peak) + defer srv.Close() + + c := limitedClient(srv.URL, 1) + + // Fill the only slot with a long-running call. + started := make(chan struct{}) + + go func() { + close(started) + + _, _ = c.Chat(t.Context(), []ai.Message{{Role: ai.RoleUser, Content: "hold"}}) + }() + + <-started + time.Sleep(10 * time.Millisecond) // let the first call take the slot + + ctx, cancel := context.WithTimeout(t.Context(), 15*time.Millisecond) + defer cancel() + + _, err := c.Chat(ctx, []ai.Message{{Role: ai.RoleUser, Content: "waits"}}) + require.ErrorIs(t, err, context.DeadlineExceeded) +} + +// A stream must free its in-flight slot when drained to exhaustion, even if the caller never Close()s. +func TestClient_MaxConcurrentRequests_StreamReleasesOnExhaustion(t *testing.T) { + srv := sseServer(t, http.StatusOK, []string{ + `data: {"choices":[{"delta":{"content":"hi"}}]}`, + `data: [DONE]`, + }) + defer srv.Close() + + c := limitedClient(srv.URL, 1) + + s1, err := c.Stream(t.Context(), []ai.Message{{Role: ai.RoleUser, Content: "a"}}) + require.NoError(t, err) + + for { + if _, ok := s1.Next(); !ok { // drain to exhaustion; deliberately no Close + break + } + } + + // With the only slot freed by exhaustion, a second stream must acquire it within a short deadline. + ctx, cancel := context.WithTimeout(t.Context(), 2*time.Second) + defer cancel() + + s2, err := c.Stream(ctx, []ai.Message{{Role: ai.RoleUser, Content: "b"}}) + require.NoError(t, err, "exhausting the first stream should have freed the slot") + + _ = s2.Close() +} + +// Closing a stream frees its slot even if it was not consumed. +func TestClient_MaxConcurrentRequests_StreamReleasesOnClose(t *testing.T) { + srv := sseServer(t, http.StatusOK, []string{ + `data: {"choices":[{"delta":{"content":"hi"}}]}`, + `data: [DONE]`, + }) + defer srv.Close() + + c := limitedClient(srv.URL, 1) + + s1, err := c.Stream(t.Context(), []ai.Message{{Role: ai.RoleUser, Content: "a"}}) + require.NoError(t, err) + require.NoError(t, s1.Close()) // release without consuming + + ctx, cancel := context.WithTimeout(t.Context(), 2*time.Second) + defer cancel() + + s2, err := c.Stream(ctx, []ai.Message{{Role: ai.RoleUser, Content: "b"}}) + require.NoError(t, err, "closing the first stream should have freed the slot") + + _ = s2.Close() +} diff --git a/pkg/gofr/ai/llm/client_embed_test.go b/pkg/gofr/ai/llm/client_embed_test.go new file mode 100644 index 0000000000..73f05a73c2 --- /dev/null +++ b/pkg/gofr/ai/llm/client_embed_test.go @@ -0,0 +1,152 @@ +package llm + +import ( + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func embedServer(t *testing.T, status int, body string, gotBody *string) *httptest.Server { + t.Helper() + + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/embeddings", r.URL.Path) + + if gotBody != nil { + b, _ := io.ReadAll(r.Body) + *gotBody = string(b) + } + + w.WriteHeader(status) + _, _ = io.WriteString(w, body) + })) +} + +func TestClient_Embed_Success(t *testing.T) { + body := `{"model":"m","data":[` + + `{"embedding":[0.1,0.2],"index":0},` + + `{"embedding":[0.3,0.4],"index":1}],` + + `"usage":{"prompt_tokens":5}}` + + var reqBody string + + srv := embedServer(t, http.StatusOK, body, &reqBody) + defer srv.Close() + + c := testClient(t, OpenAI, srv.URL) + + resp, err := c.Embed(t.Context(), []string{"hello", "world"}) + require.NoError(t, err) + require.Len(t, resp.Embeddings, 2) + assert.Equal(t, []float32{0.1, 0.2}, resp.Embeddings[0]) + assert.Equal(t, []float32{0.3, 0.4}, resp.Embeddings[1]) + assert.Equal(t, "m", resp.Model) + assert.Equal(t, 5, resp.Usage.PromptTokens) + // The request carries the configured model and the inputs, in order. + assert.JSONEq(t, `{"model":"test-model","input":["hello","world"]}`, reqBody) +} + +// A provider is allowed to return the data array in any order — that is what "index" is for. The +// vector must follow its index, not its position, or every input silently gets the wrong embedding. +func TestClient_Embed_OutOfOrderData(t *testing.T) { + body := `{"model":"m","data":[` + + `{"embedding":[0.3,0.4],"index":1},` + + `{"embedding":[0.1,0.2],"index":0}],` + + `"usage":{"prompt_tokens":5}}` + + srv := embedServer(t, http.StatusOK, body, nil) + defer srv.Close() + + c := testClient(t, OpenAI, srv.URL) + + resp, err := c.Embed(t.Context(), []string{"hello", "world"}) + require.NoError(t, err) + require.Len(t, resp.Embeddings, 2) + assert.Equal(t, []float32{0.1, 0.2}, resp.Embeddings[0]) + assert.Equal(t, []float32{0.3, 0.4}, resp.Embeddings[1]) +} + +// A minimal provider may omit "index" entirely; those entries keep their array position. +func TestClient_Embed_MissingIndexFallsBackToPosition(t *testing.T) { + body := `{"model":"m","data":[{"embedding":[0.1]},{"embedding":[0.2]}]}` + + srv := embedServer(t, http.StatusOK, body, nil) + defer srv.Close() + + c := testClient(t, OpenAI, srv.URL) + + resp, err := c.Embed(t.Context(), []string{"hello", "world"}) + require.NoError(t, err) + require.Len(t, resp.Embeddings, 2) + assert.Equal(t, []float32{0.1}, resp.Embeddings[0]) + assert.Equal(t, []float32{0.2}, resp.Embeddings[1]) +} + +func TestClient_Embed_UnmappableIndex(t *testing.T) { + tests := []struct { + name string + body string + }{ + {"index past the end", `{"data":[{"embedding":[0.1],"index":0},{"embedding":[0.2],"index":7}]}`}, + {"negative index", `{"data":[{"embedding":[0.1],"index":-1},{"embedding":[0.2],"index":1}]}`}, + {"index claimed twice", `{"data":[{"embedding":[0.1],"index":0},{"embedding":[0.2],"index":0}]}`}, + // A short response cannot be mapped either: the index bounds above are only meaningful + // against the inputs sent, so a count that disagrees is rejected rather than half-mapped. + {"fewer embeddings than inputs", `{"data":[{"embedding":[0.1],"index":0}]}`}, + {"more embeddings than inputs", `{"data":[{"embedding":[0.1],"index":0},` + + `{"embedding":[0.2],"index":1},{"embedding":[0.3],"index":2}]}`}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + srv := embedServer(t, http.StatusOK, tt.body, nil) + defer srv.Close() + + c := testClient(t, OpenAI, srv.URL) + + _, err := c.Embed(t.Context(), []string{"a", "b"}) + require.ErrorIs(t, err, errDecodeResponse) + }) + } +} + +func TestClient_Embed_ProviderErrorInBody(t *testing.T) { + srv := embedServer(t, http.StatusOK, `{"error":{"message":"bad input"}}`, nil) + defer srv.Close() + + c := testClient(t, OpenAI, srv.URL) + + _, err := c.Embed(t.Context(), []string{"x"}) + require.ErrorIs(t, err, errProvider) +} + +func TestClient_Embed_MalformedJSON(t *testing.T) { + srv := embedServer(t, http.StatusOK, `{not json`, nil) + defer srv.Close() + + c := testClient(t, OpenAI, srv.URL) + + _, err := c.Embed(t.Context(), []string{"x"}) + require.ErrorIs(t, err, errDecodeResponse) +} + +func TestClient_Embed_StatusError(t *testing.T) { + srv := embedServer(t, http.StatusBadRequest, `{"error":{"message":"nope"}}`, nil) + defer srv.Close() + + c := testClient(t, OpenAI, srv.URL) + + _, err := c.Embed(t.Context(), []string{"x"}) + require.Error(t, err) +} + +func TestClient_Embed_NotConnected(t *testing.T) { + c := &Client{Provider: OpenAI, Model: "m"} + + _, err := c.Embed(t.Context(), []string{"x"}) + require.ErrorIs(t, err, errNotConnected) +} diff --git a/pkg/gofr/ai/llm/stream.go b/pkg/gofr/ai/llm/stream.go index b1a75b6b53..e738c6ca94 100644 --- a/pkg/gofr/ai/llm/stream.go +++ b/pkg/gofr/ai/llm/stream.go @@ -33,6 +33,22 @@ func (c *Client) Stream(ctx context.Context, messages []ai.Message, opts ...ai.O return nil, errNotConnected } + // Hold an in-flight slot for the whole stream: acquire here, and hand release to the streamer so + // it frees the slot when the stream terminates (exhaustion or Close). On any early error the + // deferred guard frees it instead — once the streamer owns it, streaming flips true. + release, err := c.acquire(ctx) + if err != nil { + return nil, err + } + + streaming := false + + defer func() { + if !streaming { + release() + } + }() + body, err := c.buildRequest(messages, opts, true) if err != nil { return nil, err @@ -52,7 +68,9 @@ func (c *Client) Stream(ctx context.Context, messages []ai.Message, opts ...ai.O return nil, c.statusError(resp.StatusCode, data) } - return newStreamer(resp.Body, &c.UsageFields), nil + streaming = true + + return newStreamer(resp.Body, &c.UsageFields, release), nil } type lineStatus int @@ -71,17 +89,24 @@ type streamer struct { done bool usage ai.Usage usageFields *UsageFields + // release frees the client's in-flight concurrency slot when the stream terminates (exhaustion or + // Close). It is idempotent, so calling it from more than one termination point is safe. + release func() // tool calls are assembled from deltas keyed by index; toolOrder preserves first-seen order. toolAcc map[int]*ai.ToolCall toolOrder []int } -func newStreamer(body io.ReadCloser, fields *UsageFields) *streamer { +func newStreamer(body io.ReadCloser, fields *UsageFields, release func()) *streamer { + if release == nil { + release = func() {} + } + scanner := bufio.NewScanner(body) scanner.Buffer(make([]byte, 0, streamBufferInit), streamBufferMax) - return &streamer{body: body, scanner: scanner, usageFields: fields, toolAcc: make(map[int]*ai.ToolCall)} + return &streamer{body: body, scanner: scanner, usageFields: fields, release: release, toolAcc: make(map[int]*ai.ToolCall)} } // Next pulls the next incremental content delta. It returns the delta string and true, or nil and @@ -99,9 +124,12 @@ func (s *streamer) Next() (any, bool) { return content, true case lineDone: s.done = true + s.release() return nil, false case lineError: + s.release() // errored mid-stream — free the slot here too (idempotent), not only on Close + return nil, false case lineSkip: continue @@ -207,6 +235,8 @@ func (s *streamer) ToolCalls() []ai.ToolCall { } func (s *streamer) finish() { + s.release() // stream exhausted — free the in-flight slot (idempotent) + if err := s.scanner.Err(); err != nil { s.err = fmt.Errorf("%w: %w", errStreamRead, err) @@ -220,7 +250,11 @@ func (s *streamer) finish() { func (s *streamer) Err() error { return s.err } // Close closes the underlying response body. -func (s *streamer) Close() error { return s.body.Close() } +func (s *streamer) Close() error { + s.release() // free the in-flight slot even if the stream wasn't fully consumed (idempotent) + + return s.body.Close() +} // Usage returns token usage reported by the final chunk, or the zero value if none was sent. func (s *streamer) Usage() ai.Usage { return s.usage } diff --git a/pkg/gofr/ai/llm/stream_test.go b/pkg/gofr/ai/llm/stream_test.go index b10d0d843b..9ee6b4d882 100644 --- a/pkg/gofr/ai/llm/stream_test.go +++ b/pkg/gofr/ai/llm/stream_test.go @@ -226,7 +226,7 @@ func (c *closeTracker) Close() error { func TestStreamer_CloseClosesBody(t *testing.T) { ct := &closeTracker{Reader: strings.NewReader(`data: [DONE]` + "\n")} - s := newStreamer(ct, &UsageFields{}) + s := newStreamer(ct, &UsageFields{}, nil) _, ok := s.Next() assert.False(t, ok) diff --git a/pkg/gofr/ai/llm/wire.go b/pkg/gofr/ai/llm/wire.go index a4c93712d9..acdd3152dd 100644 --- a/pkg/gofr/ai/llm/wire.go +++ b/pkg/gofr/ai/llm/wire.go @@ -94,6 +94,30 @@ type wireResponseFunc struct { Arguments string `json:"arguments"` } +// embeddingsRequest and embeddingsResponse are the OpenAI-compatible /embeddings wire shapes. +type embeddingsRequest struct { + Model string `json:"model"` + Input []string `json:"input"` +} + +type embeddingsResponse struct { + Model string `json:"model"` + Data []embeddingDatum `json:"data"` + Usage json.RawMessage `json:"usage"` + Error *wireError `json:"error"` +} + +// embeddingDatum is one entry of the /embeddings response data array. Index names the input the +// vector belongs to; it exists in the OpenAI contract precisely so a provider may return the array +// out of order, and this client talks to any OpenAI-compatible backend, so it is honored rather +// than assumed to match the array position. It is a pointer because a minimal provider may omit +// the field entirely — absent means "use my position", where a plain int would silently claim +// index 0 for every entry. +type embeddingDatum struct { + Embedding []float32 `json:"embedding"` + Index *int `json:"index"` +} + // Default JSON paths for token usage, matching the OpenAI Chat Completions shape used by every // built-in provider (OpenAI, Groq, DeepSeek, Together, Ollama). Cache-read and reasoning counts live // under the *_details objects; DeepSeek instead reports cache hits at the top level. A custom diff --git a/pkg/gofr/ai/llm_test.go b/pkg/gofr/ai/llm_test.go index 4d73be6ee8..a2511c3699 100644 --- a/pkg/gofr/ai/llm_test.go +++ b/pkg/gofr/ai/llm_test.go @@ -204,5 +204,8 @@ func TestMockModel_SatisfiesInterfaces(t *testing.T) { _ Model = NewMockModel(ctrl) _ LLM = NewMockLLM(ctrl) _ StreamingModel = NewMockStreamingModel(ctrl) + // MockLLM stands in for the real *llm wrapper in handler tests, so it has to satisfy the + // optional capabilities a handler asserts on the LLM as well, not just LLM itself. + _ EmbeddingLLM = NewMockLLM(ctrl) ) } diff --git a/pkg/gofr/ai/mock_ai.go b/pkg/gofr/ai/mock_ai.go index 023744df64..44e332548c 100644 --- a/pkg/gofr/ai/mock_ai.go +++ b/pkg/gofr/ai/mock_ai.go @@ -134,6 +134,50 @@ func (mr *MockStreamingModelMockRecorder) Stream(ctx, messages any, opts ...any) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Stream", reflect.TypeOf((*MockStreamingModel)(nil).Stream), varargs...) } +// MockEmbedder is a mock of Embedder interface. +type MockEmbedder struct { + ctrl *gomock.Controller + recorder *MockEmbedderMockRecorder + isgomock struct{} +} + +// MockEmbedderMockRecorder is the mock recorder for MockEmbedder. +type MockEmbedderMockRecorder struct { + mock *MockEmbedder +} + +// NewMockEmbedder creates a new mock instance. +func NewMockEmbedder(ctrl *gomock.Controller) *MockEmbedder { + mock := &MockEmbedder{ctrl: ctrl} + mock.recorder = &MockEmbedderMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockEmbedder) EXPECT() *MockEmbedderMockRecorder { + return m.recorder +} + +// Embed mocks base method. +func (m *MockEmbedder) Embed(ctx context.Context, input []string, opts ...Option) (*EmbeddingResponse, error) { + m.ctrl.T.Helper() + varargs := []any{ctx, input} + for _, a := range opts { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "Embed", varargs...) + ret0, _ := ret[0].(*EmbeddingResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Embed indicates an expected call of Embed. +func (mr *MockEmbedderMockRecorder) Embed(ctx, input any, opts ...any) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]any{ctx, input}, opts...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Embed", reflect.TypeOf((*MockEmbedder)(nil).Embed), varargs...) +} + // MockDescriptor is a mock of Descriptor interface. type MockDescriptor struct { ctrl *gomock.Controller @@ -172,18 +216,18 @@ func (mr *MockDescriptorMockRecorder) ModelName() *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ModelName", reflect.TypeOf((*MockDescriptor)(nil).ModelName)) } -// Provider mocks base method. -func (m *MockDescriptor) Provider() string { +// ProviderName mocks base method. +func (m *MockDescriptor) ProviderName() string { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Provider") + ret := m.ctrl.Call(m, "ProviderName") ret0, _ := ret[0].(string) return ret0 } -// Provider indicates an expected call of Provider. -func (mr *MockDescriptorMockRecorder) Provider() *gomock.Call { +// ProviderName indicates an expected call of ProviderName. +func (mr *MockDescriptorMockRecorder) ProviderName() *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Provider", reflect.TypeOf((*MockDescriptor)(nil).Provider)) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ProviderName", reflect.TypeOf((*MockDescriptor)(nil).ProviderName)) } // MockLLM is a mock of LLM interface. @@ -230,6 +274,26 @@ func (mr *MockLLMMockRecorder) Chat(ctx, messages any, opts ...any) *gomock.Call return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Chat", reflect.TypeOf((*MockLLM)(nil).Chat), varargs...) } +// Embed mocks base method. +func (m *MockLLM) Embed(ctx context.Context, input []string, opts ...Option) (*EmbeddingResponse, error) { + m.ctrl.T.Helper() + varargs := []any{ctx, input} + for _, a := range opts { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "Embed", varargs...) + ret0, _ := ret[0].(*EmbeddingResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Embed indicates an expected call of Embed. +func (mr *MockLLMMockRecorder) Embed(ctx, input any, opts ...any) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]any{ctx, input}, opts...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Embed", reflect.TypeOf((*MockLLM)(nil).Embed), varargs...) +} + // Generate mocks base method. func (m *MockLLM) Generate(ctx context.Context, prompt string, opts ...Option) (*Response, error) { m.ctrl.T.Helper() @@ -449,3 +513,41 @@ func (mr *MockStreamerMockRecorder) Next() *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Next", reflect.TypeOf((*MockStreamer)(nil).Next)) } + +// MockToolCallStreamer is a mock of ToolCallStreamer interface. +type MockToolCallStreamer struct { + ctrl *gomock.Controller + recorder *MockToolCallStreamerMockRecorder + isgomock struct{} +} + +// MockToolCallStreamerMockRecorder is the mock recorder for MockToolCallStreamer. +type MockToolCallStreamerMockRecorder struct { + mock *MockToolCallStreamer +} + +// NewMockToolCallStreamer creates a new mock instance. +func NewMockToolCallStreamer(ctrl *gomock.Controller) *MockToolCallStreamer { + mock := &MockToolCallStreamer{ctrl: ctrl} + mock.recorder = &MockToolCallStreamerMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockToolCallStreamer) EXPECT() *MockToolCallStreamerMockRecorder { + return m.recorder +} + +// ToolCalls mocks base method. +func (m *MockToolCallStreamer) ToolCalls() []ToolCall { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ToolCalls") + ret0, _ := ret[0].([]ToolCall) + return ret0 +} + +// ToolCalls indicates an expected call of ToolCalls. +func (mr *MockToolCallStreamerMockRecorder) ToolCalls() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ToolCalls", reflect.TypeOf((*MockToolCallStreamer)(nil).ToolCalls)) +} diff --git a/pkg/gofr/ai/model.go b/pkg/gofr/ai/model.go index d5be86ad6d..20952449ad 100644 --- a/pkg/gofr/ai/model.go +++ b/pkg/gofr/ai/model.go @@ -25,6 +25,27 @@ type StreamingModel interface { Stream(ctx context.Context, messages []Message, opts ...Option) (Streamer, error) } +// Embedder is the model-side optional capability a provider implements to turn text into embedding +// vectors — the peer of StreamingModel for streaming. It is not every provider's capability (a +// chat-only model has no embeddings, an embedding model has no chat), so callers do not implement or +// assert it directly: they assert EmbeddingLLM on ctx.LLM(), whose Embed reports +// ErrEmbedNotSupported when the underlying provider does not implement Embedder. Embeddings power +// semantic search and agent memory: embed text on write, embed a query on read, and rank stored +// vectors by similarity. +type Embedder interface { + Embed(ctx context.Context, input []string, opts ...Option) (*EmbeddingResponse, error) +} + +// EmbeddingResponse is the result of an Embed call. +type EmbeddingResponse struct { + // Embeddings holds one vector per input string, in the same order as the input. + Embeddings [][]float32 + // Usage reports token consumption; embeddings bill input (prompt) tokens only. + Usage Usage + // Model is the resolved model that produced the vectors. + Model string +} + // Descriptor is an optional interface a provider implements to report distinct provider and model // labels for metrics and traces. Without it, Name() is used for both labels. The methods are named // ProviderName/ModelName (not Provider/Model) so a provider can expose Provider and Model as @@ -45,6 +66,25 @@ type LLM interface { Tools() Tools } +// EmbeddingLLM is the caller-side optional capability for embeddings — the first interface added +// under LLM's freeze, and the pattern for every capability after it. The LLM returned by ctx.LLM() +// always implements it, so the assertion never fails in a handler; Embed then reports +// ErrEmbedNotSupported when the configured provider is chat-only, and ErrLLMNotConfigured when no +// model is registered. +// +// e, ok := ctx.LLM().(ai.EmbeddingLLM) +// if !ok { +// return nil, errors.New("embeddings unavailable") +// } +// +// resp, err := e.Embed(ctx, []string{"hello"}) +// +// It is asserted rather than declared on LLM so that adding embeddings does not break the +// hand-written fakes and third-party wrappers LLM promises to keep compiling. +type EmbeddingLLM interface { + Embed(ctx context.Context, input []string, opts ...Option) (*EmbeddingResponse, error) +} + // Tools is the set of the service's own handlers exposed as agent-callable tools. It is frozen on // the same terms as LLM; grow it via new optional interfaces. type Tools interface { diff --git a/pkg/gofr/container/container.go b/pkg/gofr/container/container.go index 799cd083bb..812a202f52 100644 --- a/pkg/gofr/container/container.go +++ b/pkg/gofr/container/container.go @@ -323,6 +323,11 @@ func (c *Container) LLM(name ...string) ai.LLM { // are independent of any registered model. type notConfiguredLLM struct{ c *Container } +// Every LLM returned from the container must satisfy the optional capability interfaces, so a +// handler's type assertion succeeds whether or not a model is configured — the absence is then +// reported as ai.ErrLLMNotConfigured from the call itself, not as a failed assertion. +var _ ai.EmbeddingLLM = notConfiguredLLM{} + func (notConfiguredLLM) Chat(context.Context, []ai.Message, ...ai.Option) (*ai.Response, error) { return nil, ai.ErrLLMNotConfigured } @@ -335,6 +340,10 @@ func (notConfiguredLLM) Stream(context.Context, []ai.Message, ...ai.Option) (ai. return nil, ai.ErrLLMNotConfigured } +func (notConfiguredLLM) Embed(context.Context, []string, ...ai.Option) (*ai.EmbeddingResponse, error) { + return nil, ai.ErrLLMNotConfigured +} + func (notConfiguredLLM) HealthCheck(context.Context) datasource.Health { return datasource.Health{Status: datasource.StatusDown} } From 878b026588c2228bba57c22df6e9217b870888c0 Mon Sep 17 00:00:00 2001 From: Aryan Mehrotra Date: Tue, 18 Aug 2026 12:54:27 +0530 Subject: [PATCH 07/20] perf(http): opt-in O(1) trie router behind GOFR_ROUTER (#3759) --- .../routing-performance/page.md | 103 +++ docs/navigation.js | 5 + docs/references/configs/page.md | 6 + pkg/gofr/http/middleware/metrics.go | 20 +- pkg/gofr/http/middleware/tracer.go | 15 +- pkg/gofr/http/route_context.go | 45 ++ pkg/gofr/http/route_context_test.go | 68 ++ pkg/gofr/http/router.go | 211 ++++- pkg/gofr/http/trie_router.go | 417 ++++++++++ pkg/gofr/http/trie_router_test.go | 749 ++++++++++++++++++ pkg/gofr/http_server.go | 28 + pkg/gofr/http_server_test.go | 62 ++ 12 files changed, 1692 insertions(+), 37 deletions(-) create mode 100644 docs/advanced-guide/routing-performance/page.md create mode 100644 pkg/gofr/http/route_context.go create mode 100644 pkg/gofr/http/route_context_test.go create mode 100644 pkg/gofr/http/trie_router.go create mode 100644 pkg/gofr/http/trie_router_test.go diff --git a/docs/advanced-guide/routing-performance/page.md b/docs/advanced-guide/routing-performance/page.md new file mode 100644 index 0000000000..5db0545e5b --- /dev/null +++ b/docs/advanced-guide/routing-performance/page.md @@ -0,0 +1,103 @@ +--- +description: "Speed up route matching in GoFr with the opt-in trie router. Set GOFR_ROUTER=trie to make matching cost O(path length) instead of scaling with your route count." +nextjs: + metadata: + title: "Routing Performance in GoFr — The Opt-In Trie Router" + description: "Speed up route matching in GoFr with the opt-in trie router. Set GOFR_ROUTER=trie to make matching cost O(path length) instead of scaling with your route count." +--- + +# Routing Performance + +GoFr routes on `gorilla/mux`, which finds a handler by walking the registered routes in order and +testing each one against the request path. That is O(n) in the number of routes, so a service pays a +little more per request for every route it adds. + +Setting `GOFR_ROUTER=trie` swaps the matching step for a segment trie, making it O(path length) — +flat as the route table grows. Everything else is unchanged: `mux` is still the route registry, and +`mux` still makes the final decision about which route matches. + +```bash +# configs/.env +GOFR_ROUTER=trie +``` + +It is **off by default**. Leave it unset and your service behaves exactly as it always has. + +## Whether it will help you + +The win scales with the size of your route table, so it is worth being concrete about where the line +is. Measured on an Apple M4, with the request hitting the middle of the table: + +| Routes | Default (mux) | `GOFR_ROUTER=trie` | Speedup | +| -----: | ------------: | -----------------: | ------: | +| 1 | 431 ns | 447 ns | 0.96x | +| 10 | 506 ns | 461 ns | 1.1x | +| 50 | 1007 ns | 550 ns | 1.8x | +| 100 | 1642 ns | 548 ns | 3.0x | +| 200 | 2918 ns | 515 ns | 5.7x | + +The crossover is around 5–10 routes. Below that the trie is marginally slower, so a small service +gains nothing by turning it on. + +Two further caveats worth setting expectations against: + +- **Matching is a minority of a request.** The middleware chain — tracing, logging, metrics, CORS — + dominates. So end-to-end throughput moves by less than the table above, approaching it only as the + route count grows. +- **The trie allocates slightly more.** Two extra allocations per matched request, for restoring the + path params and route template. This is a CPU and scaling win, not an allocation win. + +## What stays the same + +Routing behavior is unchanged, and that is a property the framework tests for rather than a hope. +The trie only *narrows* the set of routes worth considering; `mux`'s own `Route.Match` still decides +every request, so method matching, `{id:[0-9]+}` constraints, header and query matchers, route +ordering and path cleaning all behave exactly as they do by default. Anything the trie cannot index +— `PathPrefix` routes, static file handlers, slash-spanning parameters like `{path:.*}` — is handled +by `mux` directly. Requests that match nothing are handed to `mux` in full. + +Path parameters are unaffected: `ctx.PathParam("id")` and `mux.Vars(r)` work identically. + +## The one thing to check in your own code + +The trie serves matched requests without going through `mux`'s own `ServeHTTP`, which is what +populates `mux.CurrentRoute`. If any of your handlers or middleware calls it: + +```go +// Returns nil when GOFR_ROUTER=trie. +route := mux.CurrentRoute(r) +tmpl, _ := route.GetPathTemplate() +``` + +use GoFr's accessor instead. It resolves the template under both routers, so it is safe to adopt +before you flip the flag: + +```go +import gofrHTTP "gofr.dev/pkg/gofr/http" + +tmpl := gofrHTTP.RouteTemplate(r) // "/users/{id}", or "" if nothing matched +``` + +`mux.Vars(r)` is **not** affected and needs no change. + +## Confirming which matcher is active + +GoFr logs the matcher at startup whenever `GOFR_ROUTER` is set: + +``` +INFO HTTP route matcher: trie +``` + +A value it does not recognize falls back to `mux` and says so, so a typo does not cost you the opt-in +silently: + +``` +WARN unrecognized GOFR_ROUTER value "tri", using the "mux" router; valid values are "mux" and "trie" +``` + +## A note on registering routes late + +The index is built once, from the routes present when the first request arrives. GoFr registers every +route during startup, before the server begins accepting requests, so this holds for all framework +code paths. A route added after the server is already serving would not be indexed — it would still +be served correctly, via `mux`, just without the speedup. diff --git a/docs/navigation.js b/docs/navigation.js index 8bca8f9127..ecfa6b99ff 100644 --- a/docs/navigation.js +++ b/docs/navigation.js @@ -79,6 +79,11 @@ export const navigation = [ href: '/docs/advanced-guide/http-communication', desc: "Get familiar with making HTTP requests and handling responses within your GoFr application to facilitate seamless communication." }, + { + title: 'Routing Performance', + href: '/docs/advanced-guide/routing-performance', + desc: "Opt into the trie router with GOFR_ROUTER=trie to keep route matching flat as your route table grows, instead of scaling with the number of routes." + }, { title: 'Authentication', href: '/docs/advanced-guide/authentication', diff --git a/docs/references/configs/page.md b/docs/references/configs/page.md index 87834a2bff..e4912f1036 100644 --- a/docs/references/configs/page.md +++ b/docs/references/configs/page.md @@ -171,6 +171,12 @@ This document lists all the configuration options supported by the GoFr framewor --- +- GOFR_ROUTER +- Route matcher for the HTTP server. Set to `trie` to opt into the O(path length) trie index instead of the default linear scan — see [Routing Performance](/docs/advanced-guide/routing-performance). Any other value falls back to `mux`. +- mux + +--- + - LOG_DISABLE_PROBES - Disable log probes for health checks - false diff --git a/pkg/gofr/http/middleware/metrics.go b/pkg/gofr/http/middleware/metrics.go index cd1ccd859b..7bf6c883a5 100644 --- a/pkg/gofr/http/middleware/metrics.go +++ b/pkg/gofr/http/middleware/metrics.go @@ -10,8 +10,9 @@ import ( "sync" "time" - "github.com/gorilla/mux" "go.opentelemetry.io/otel/attribute" + + gofrhttp "gofr.dev/pkg/gofr/http" ) type metrics interface { @@ -89,16 +90,13 @@ func Metrics(metrics metrics) func(inner http.Handler) http.Handler { srw = &StatusResponseWriter{ResponseWriter: w} } - // mux.CurrentRoute is nil for unmatched routes (404), and even - // when matched, GetPathTemplate can return "" for routes built - // without an explicit Path() (e.g. PathPrefix-only handlers). - // Fall back to r.URL.Path in both cases so the metric carries a - // usable path label instead of caching an empty key. - var path string - if cr := mux.CurrentRoute(r); cr != nil { - path, _ = cr.GetPathTemplate() - } - + // Resolve the route template for the metric label via the + // router-agnostic accessor (it reads the trie router's context key + // or mux.CurrentRoute, whichever applies). It is "" for unmatched + // routes and for routes built without an explicit Path() (e.g. + // PathPrefix-only handlers), so fall back to r.URL.Path there to + // keep a usable path label rather than an empty key. + path := gofrhttp.RouteTemplate(r) if path == "" { path = r.URL.Path } diff --git a/pkg/gofr/http/middleware/tracer.go b/pkg/gofr/http/middleware/tracer.go index c828f8198a..f97062259c 100644 --- a/pkg/gofr/http/middleware/tracer.go +++ b/pkg/gofr/http/middleware/tracer.go @@ -5,12 +5,12 @@ import ( "net/http" "strings" - "github.com/gorilla/mux" "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/propagation" "go.opentelemetry.io/otel/trace" + gofrhttp "gofr.dev/pkg/gofr/http" "gofr.dev/pkg/gofr/version" ) @@ -31,14 +31,13 @@ func methodKV(method string) attribute.KeyValue { } // routeTemplate returns the matched route template (e.g. "/users/{id}") for -// the request when gorilla/mux has resolved one, otherwise the raw URL path. -// Used for the span name and http.route attribute so tracing cardinality -// stays bounded by route count, not request count. +// the request, otherwise the raw URL path. Used for the span name and +// http.route attribute so tracing cardinality stays bounded by route count, +// not request count. gofrhttp.RouteTemplate resolves the template under both +// the trie and the default mux router. func routeTemplate(r *http.Request) string { - if route := mux.CurrentRoute(r); route != nil { - if t, err := route.GetPathTemplate(); err == nil && t != "" { - return t - } + if t := gofrhttp.RouteTemplate(r); t != "" { + return t } return r.URL.Path diff --git a/pkg/gofr/http/route_context.go b/pkg/gofr/http/route_context.go new file mode 100644 index 0000000000..fc3f869906 --- /dev/null +++ b/pkg/gofr/http/route_context.go @@ -0,0 +1,45 @@ +package http + +import ( + "context" + "net/http" + + "github.com/gorilla/mux" +) + +// routeTemplateCtxKey is the private context key under which the trie router +// stores the matched route template (e.g. "/users/{id}"). It exists because, +// once the trie router bypasses mux's ServeHTTP, mux.CurrentRoute(r) is no +// longer populated, so the template must be carried some other way. +type routeTemplateCtxKey struct{} + +// withRouteTemplate returns r carrying tmpl as the matched route template. An +// empty template is a no-op so unmatched requests do not pay for a context copy. +func withRouteTemplate(r *http.Request, tmpl string) *http.Request { + if tmpl == "" { + return r + } + + return r.WithContext(context.WithValue(r.Context(), routeTemplateCtxKey{}, tmpl)) +} + +// RouteTemplate returns the matched route template for r (e.g. "/users/{id}"), +// or "" if none is available. It is the router-agnostic replacement for +// mux.CurrentRoute(r).GetPathTemplate() and works under both routers: the trie +// router records the template in the request context (it bypasses mux's +// ServeHTTP, so mux.CurrentRoute is nil), while the default mux path exposes it +// via mux.CurrentRoute. Callers get consistent behavior without knowing which +// router is active. +func RouteTemplate(r *http.Request) string { + if tmpl, _ := r.Context().Value(routeTemplateCtxKey{}).(string); tmpl != "" { + return tmpl + } + + if route := mux.CurrentRoute(r); route != nil { + if tmpl, err := route.GetPathTemplate(); err == nil { + return tmpl + } + } + + return "" +} diff --git a/pkg/gofr/http/route_context_test.go b/pkg/gofr/http/route_context_test.go new file mode 100644 index 0000000000..e187e832a3 --- /dev/null +++ b/pkg/gofr/http/route_context_test.go @@ -0,0 +1,68 @@ +package http + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestRouteTemplate_ResolvesUnderBothRouters is the guard for the accessor the +// tracer and metrics middleware depend on for their route label. It must return +// the route template — not the raw request path — under BOTH routers: the trie +// router records it in the request context (it bypasses mux's ServeHTTP, so +// mux.CurrentRoute is nil there), while the default mux router exposes it via +// mux.CurrentRoute. A silent regression here would degrade the metric label to +// an unbounded raw path without failing any other test. +func TestRouteTemplate_ResolvesUnderBothRouters(t *testing.T) { + const tmpl = "/users/{id}" + + for _, tc := range []struct { + name string + useTrie bool + }{ + {"mux", false}, + {"trie", true}, + } { + t.Run(tc.name, func(t *testing.T) { + var got string + + r := NewRouter() + r.useTrie = tc.useTrie + r.Add(http.MethodGet, tmpl, http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + got = RouteTemplate(req) + + w.WriteHeader(http.StatusOK) + })) + + req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/users/42", http.NoBody) + rec := httptest.NewRecorder() + r.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code, "handler must have run") + assert.Equal(t, tmpl, got, "route template must resolve under the %s router", tc.name) + }) + } +} + +// TestRouteTemplate_EmptyWhenUnmatched asserts the accessor reports no template +// for a request that matched no route, so callers fall back to the raw path +// rather than reading a stale or bogus label. +func TestRouteTemplate_EmptyWhenUnmatched(t *testing.T) { + req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/nothing", http.NoBody) + + assert.Empty(t, RouteTemplate(req), "no template should be reported for an unrouted request") +} + +// TestWithRouteTemplate_EmptyIsNoOp verifies that recording an empty template +// does not copy the request — unmatched requests must not pay for a context +// allocation. +func TestWithRouteTemplate_EmptyIsNoOp(t *testing.T) { + req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/x", http.NoBody) + + assert.Same(t, req, withRouteTemplate(req, ""), "empty template must not copy the request") + assert.NotSame(t, req, withRouteTemplate(req, "/x/{id}"), "a real template must be recorded") +} diff --git a/pkg/gofr/http/router.go b/pkg/gofr/http/router.go index f2f36aa9e5..e94352c039 100644 --- a/pkg/gofr/http/router.go +++ b/pkg/gofr/http/router.go @@ -9,6 +9,7 @@ import ( "path" "path/filepath" "strings" + "sync" "github.com/gorilla/mux" @@ -19,6 +20,16 @@ const ( DefaultSwaggerFileName = "openapi.json" staticServerNotFoundFileName = "404.html" staticServerIndexFileName = "index.html" + + // RouterEnvVar selects the route matcher. Unset (or any unrecognized value) + // means MatcherMux, so the default behavior is unchanged. + RouterEnvVar = "GOFR_ROUTER" + + // MatcherMux is gorilla/mux's linear scan — the default. + MatcherMux = "mux" + // MatcherTrie is the opt-in segment-trie index, O(path length) in the number + // of registered routes. + MatcherTrie = "trie" ) // errReadPermissionDenied wraps fs.ErrPermission so that a file whose mode carries no read bit is @@ -30,6 +41,31 @@ var errReadPermissionDenied = fmt.Errorf("file does not have read permission: %w type Router struct { mux.Router RegisteredRoutes *[]string + + // useTrie selects the O(path) trie matcher (GOFR_ROUTER=trie) over mux's + // default O(n) linear scan. When false, ServeHTTP delegates to mux exactly + // as before, so the default behavior is byte-for-byte unchanged. + useTrie bool + // idx is the trie index. It is built once, lazily, on the first request, + // from the routes registered up to that point. This is correct for GoFr's + // lifecycle: every route is registered during startup (app.GET/POST/..., + // the GraphQL route, the static/catch-all handlers) before the server + // accepts its first request, and GoFr does not add routes afterwards. A + // route registered after the first request would not be reflected in the + // trie index — a deliberate trade for a lock-free steady state, matching + // GoFr's static-routing model. buildIdx guards that one-time build. + idx *routeIndex + buildIdx sync.Once + // mws mirrors the middleware chain registered via Use, so the trie matcher + // can apply it itself when it bypasses mux's ServeHTTP. In mux mode it is + // unused (mux owns the chain) but kept in sync, costing nothing. + // + // Invariant: every middleware MUST be registered through (*Router).Use (or + // UseMiddleware, which calls it). A direct call to the embedded + // mux.Router.Use would bypass this slice and be silently dropped in trie + // mode. All framework registration paths go through (*Router).Use, and the + // MiddlewareParity differential test guards the resulting behavior. + mws []mux.MiddlewareFunc } type Middleware func(handler http.Handler) http.Handler @@ -41,45 +77,184 @@ func NewRouter() *Router { r := &Router{ Router: *muxRouter, RegisteredRoutes: &routes, + useTrie: strings.EqualFold(os.Getenv(RouterEnvVar), MatcherTrie), } - r.Router = *muxRouter - return r } // ServeHTTP implements [http.Handler] interface with path normalization. func (rou *Router) ServeHTTP(w http.ResponseWriter, r *http.Request) { - // Normalize the path before routing to handle double slashes + normalizePath(r) + + if rou.useTrie { + rou.serveTrie(w, r) + + return + } + + // Delegate to the underlying Gorilla Mux router. + rou.Router.ServeHTTP(w, r) +} + +// normalizePath canonicalizes r.URL.Path in place so routing sees a clean path +// (no "//", "/.", "/.." or non-root trailing slash), matching the behavior both +// the mux and trie matchers rely on. +func normalizePath(r *http.Request) { originalPath := r.URL.Path // Fast path: the vast majority of incoming paths are already canonical // ("/users/42", "/api/v1/things"). Skip the path.Clean + string ops in // that case so they only run for inputs that actually need normalizing. - if !isCleanPath(originalPath) { - normalizedPath := path.Clean(originalPath) + if isCleanPath(originalPath) { + return + } - // path.Clean returns "." for empty paths, convert to "/" for HTTP routing - if normalizedPath == "." { - normalizedPath = "/" - } + normalizedPath := path.Clean(originalPath) - // Ensure path starts with "/" for HTTP routing - normalizedPath = "/" + strings.TrimLeft(normalizedPath, "/") + // path.Clean returns "." for empty paths, convert to "/" for HTTP routing + if normalizedPath == "." { + normalizedPath = "/" + } - // Only modify if path changed - if originalPath != normalizedPath { - r.URL.Path = normalizedPath - if r.URL.RawPath != "" { - r.URL.RawPath = normalizedPath - } + // Ensure path starts with "/" for HTTP routing + normalizedPath = "/" + strings.TrimLeft(normalizedPath, "/") + + // Only modify if path changed + if originalPath != normalizedPath { + r.URL.Path = normalizedPath + if r.URL.RawPath != "" { + r.URL.RawPath = normalizedPath } } +} + +// serveTrie handles a request using the trie matcher: it matches (delegating the +// real decision to mux's Route.Match), restores the path params and route +// template that mux's own ServeHTTP would have set, then runs the matched +// handler through GoFr's middleware chain. +// +// The middleware chain wraps the matched handler ONLY. mux builds its chain +// inside Match, guarded by MatchErr == nil, so it never wraps the NotFound or +// MethodNotAllowed handlers — a 404/405 request is served by mux without the +// Tracer/Logging/CORS/Metrics chain running. serveTrie mirrors that exactly, so +// unmatched requests are not logged, measured, or CORS-answered (and the metrics +// path label is never populated from a raw, unbounded request path). +// +// Anything the trie does not match is handed to mux's own ServeHTTP rather than +// resolved here — see the comment on that call for why. +func (rou *Router) serveTrie(w http.ResponseWriter, r *http.Request) { + rou.buildIdx.Do(func() { + idx := newRouteIndex() + idx.build(&rou.Router) + rou.idx = idx + }) + + var match mux.RouteMatch - // Delegate to the underlying Gorilla Mux router + if rou.idx.match(r, &match) && match.Handler != nil { + rou.serveMatched(w, r, &match) + + return + } + + // Unmatched by the trie: hand the request to mux's own ServeHTTP instead of + // deciding 404-vs-405 here. This is the cold path — the response is already + // an error — so mux's linear scan costs nothing that matters, and it buys two + // things that reimplementing the decision cannot. + // + // Exactness. mux's 405 depends on state that routes which do NOT match the + // request path still mutate: a later route whose method matcher succeeds + // clears an ErrMethodMismatch left by an earlier one (gorilla/mux route.go), + // and GoFr registers routes as Methods(m).Path(p), so the method matcher runs + // first. Reproducing that outcome therefore requires visiting routes the trie + // exists to skip. Delegating gets it exactly right instead of approximately. + // The custom NotFoundHandler / MethodNotAllowedHandler and subrouter + // semantics come along for free, and mux applies no middleware on this path, + // so the parity the chain relies on is mux's own by construction. + // + // Safety. It also makes the index self-healing: if isIndexablePathRegexp ever + // admitted a shape it should not have and the trie dropped a route that does + // match, mux's full scan finds it here and serves it correctly. That turns the + // one catastrophic failure mode of this design — a live route silently + // 404ing — into a request that is merely slower, leaving the trie strictly an + // accelerator. + // + // Inside a GoFr app this is unreachable: the PathPrefix("/") catch-all matches + // every path and method, so the trie always has a candidate that matches. rou.Router.ServeHTTP(w, r) } +// serveMatched runs a handler the trie matched, after restoring the request state that mux's own +// ServeHTTP would have populated. +func (rou *Router) serveMatched(w http.ResponseWriter, r *http.Request, match *mux.RouteMatch) { + // Reinstate what mux.Router.ServeHTTP would have populated so that mux.Vars(r) (used by + // request.go and user handlers) and the route template (used by the tracer/metrics middleware) + // keep working. + if match.Vars != nil { + r = mux.SetURLVars(r, match.Vars) + } + + if match.Route != nil { + if tmpl, err := match.Route.GetPathTemplate(); err == nil { + r = withRouteTemplate(r, tmpl) + } + } + + // mux builds its middleware chain inside Match, guarded by MatchErr == nil, so a route that + // matches while REPORTING an error is served WITHOUT the chain. A subrouter carrying its own + // NotFoundHandler is that case: it reports a successful match with ErrNotFound. Running the chain + // here would log, trace, meter and CORS-answer a request mux leaves uninstrumented — a silent + // difference, since the status and body are identical either way. + // + // The mirror only needs this one guard. A subrouter's MethodNotAllowedHandler also matches + // successfully, but mux clears ErrMethodMismatch as soon as one of the route's matchers succeeds + // (the else arm of the matcher loop in gorilla/mux route.go), so that case arrives here with a nil + // MatchErr and correctly DOES get the chain. + if match.MatchErr != nil { + match.Handler.ServeHTTP(w, r) + + return + } + + composeMiddleware(rou.mws, match.Handler).ServeHTTP(w, r) +} + +// Use registers mux middlewares. It records them in GoFr's own chain — so the +// trie matcher can apply them when it bypasses mux's ServeHTTP — and delegates +// to the embedded mux router, leaving the default (mux) path unchanged. It +// shadows mux.Router.Use for calls made on *Router. +func (rou *Router) Use(mwf ...mux.MiddlewareFunc) { + rou.mws = append(rou.mws, mwf...) + rou.Router.Use(mwf...) +} + +// Matcher reports which route matcher this router uses: MatcherTrie for the +// opt-in index, MatcherMux for the default linear scan. +// +// It is exported so the server can state the active matcher at startup. The +// choice is made from the environment inside NewRouter, and an unrecognized +// GOFR_ROUTER value falls back to mux — which is indistinguishable, from the +// outside, from not setting the variable at all. Reporting the resolved matcher +// is what lets the caller tell a typo from a default. +func (rou *Router) Matcher() string { + if rou.useTrie { + return MatcherTrie + } + + return MatcherMux +} + +// composeMiddleware wraps h with mws so that mws[0] is the outermost layer, +// matching the order in which mux applies its middleware chain. +func composeMiddleware(mws []mux.MiddlewareFunc, h http.Handler) http.Handler { + for i := len(mws) - 1; i >= 0; i-- { + h = mws[i](h) + } + + return h +} + // isCleanPath reports whether p is already canonical — starts with "/", no // "//", no "/.", no "/..", and no trailing slash (except the root). When // true, path.Clean(p) == p and the surrounding normalization can be skipped. diff --git a/pkg/gofr/http/trie_router.go b/pkg/gofr/http/trie_router.go new file mode 100644 index 0000000000..8607c39901 --- /dev/null +++ b/pkg/gofr/http/trie_router.go @@ -0,0 +1,417 @@ +package http + +import ( + "net/http" + "regexp/syntax" + "strings" + + "github.com/gorilla/mux" +) + +// routeEntry pairs a registered mux route with its registration order. The +// order lets the index reproduce mux's "first registered wins" semantics after +// the trie has narrowed the candidate set. +type routeEntry struct { + route *mux.Route + order int +} + +// trieNode indexes routes by path segment. A literal segment ("users") is keyed +// in children; a parameter segment ("{id}" or "{id:[0-9]+}") uses paramChild. +// Routes whose template ends at this node are collected in routes. +type trieNode struct { + children map[string]*trieNode + paramChild *trieNode + routes []*routeEntry +} + +func newTrieNode() *trieNode { return &trieNode{children: make(map[string]*trieNode)} } + +// routeIndex accelerates route matching to O(path length) — independent of the +// number of registered routes — by narrowing the candidate set with a segment +// trie and then delegating the ACTUAL match to mux's own Route.Match. Because +// the final decision is still mux's, every mux semantic (method matching, +// {id:regex} constraints, header/query/host matchers, path cleaning) is +// preserved exactly; the trie only avoids mux's O(n) linear scan. +// +// The O(path length) property applies to trie-indexable routes (plain exact +// paths). Routes that cannot be indexed (PathPrefix/static, slash-spanning +// params) live in the fallback list, which is scanned on every request — so an +// app that registers many such routes stays linear in the size of that set. In +// practice the fallback set is small (a handful of static/catch-all routes), so +// the "flat as route count grows" result holds for realistic route tables. +type routeIndex struct { + root *trieNode + fallback []*routeEntry // routes with no indexable path template (PathPrefix, host-only, ...) +} + +func newRouteIndex() *routeIndex { return &routeIndex{root: newTrieNode()} } + +// build indexes every route the given mux router knows about, in registration +// order. Routes with a plain segmented path go into the trie; anything else +// (PathPrefix, host-only, matcher-only) goes into the order-preserving fallback +// list so it is still considered on every request. +func (idx *routeIndex) build(router *mux.Router) { + order := 0 + + // Walk visits routes in registration order. The walk func never returns an + // error, so the walk always completes and every route is classified. + _ = router.Walk(func(route *mux.Route, _ *mux.Router, _ []*mux.Route) error { + e := &routeEntry{route: route, order: order} + order++ + + tpl, ok := exactPathTemplate(route) + if !ok { + idx.fallback = append(idx.fallback, e) + + return nil + } + + idx.insert(tpl, e) + + return nil + }) +} + +// exactPathTemplate returns the path template of route, and true only when the +// route's shape can be proven trie-indexable by isIndexablePathRegexp — a fully +// anchored path whose every segment maps to exactly one request-path segment. +// Everything else goes to the always-evaluated fallback list, where mux's own +// Route.Match decides it. +// +// A segment that mixes a literal with a parameter ("{name}.txt", "user-{id}") is +// still indexable: it matches exactly one path segment, so it is inserted as a +// parameter slot (see segmentHasParam) and mux validates the intra-segment +// pattern. +// +// Trie membership is purely an acceleration decision: correctness never depends +// on it, because match() runs mux's real Route.Match on whichever candidate is +// selected regardless of which list it came from. Host/header/query matchers on +// an otherwise-exact path are thus safe to index — Route.Match still enforces +// them — so they are intentionally not excluded here. +func exactPathTemplate(route *mux.Route) (string, bool) { + tpl, err := route.GetPathTemplate() + if err != nil || !strings.HasPrefix(tpl, "/") { + return "", false + } + + rx, err := route.GetPathRegexp() + if err != nil { + return "", false + } + + if !isIndexablePathRegexp(rx) { + return "", false + } + + return tpl, true +} + +// isIndexablePathRegexp decides, from mux's compiled path regexp, whether a +// route's shape is one the segment trie can locate. It is an ALLOWLIST: it +// returns true only for a pattern it can positively prove is +// +// - anchored at both ends (an exact path, not a PathPrefix), and +// - built from captures that each match a non-empty, slash-free fragment, +// +// so the template's segment count always equals the request's segment count. +// Anything it cannot prove — a regexp it cannot parse, a prefix pattern, a capture +// that may span "/" ("{p:.*}") or match "" ("{p:[a-z]*}") — returns false and +// the route goes to the fallback list, where mux's own Route.Match decides it. +// +// An allowlist is deliberate. Trie membership is only an acceleration decision +// (match() always runs mux's real Route.Match on the candidates it produces), so +// a route wrongly EXCLUDED merely loses speed, while a route wrongly INCLUDED is +// a correctness bug — it can be dropped from the candidate set entirely and +// silently 404 or fall through to another handler. Proving the shape is safe, +// rather than enumerating unsafe shapes, keeps that failure mode impossible. +// +// Anchoring is checked on the parsed syntax tree, not by a "$" string suffix: a +// template containing a literal "$" is QuoteMeta-escaped by mux, so a PathPrefix +// such as "/p$" compiles to the unanchored "^/p\\$" — which ends in the byte "$" +// yet must NOT be treated as an exact path. +func isIndexablePathRegexp(rx string) bool { + re, err := syntax.Parse(rx, syntax.Perl) + if err != nil { + return false + } + + return isAnchoredBothEnds(re) && capturesAreSingleSegment(re) +} + +// isAnchoredBothEnds reports whether re begins with a begin-text anchor and ends +// with an end-text anchor — mux's shape for an exact path ("^/users/([^/]+)$") +// as opposed to a prefix ("^/static/"). +func isAnchoredBothEnds(re *syntax.Regexp) bool { + if re.Op != syntax.OpConcat || len(re.Sub) < 2 { + return false + } + + first, last := re.Sub[0], re.Sub[len(re.Sub)-1] + + beginOK := first.Op == syntax.OpBeginText || first.Op == syntax.OpBeginLine + endOK := last.Op == syntax.OpEndText || last.Op == syntax.OpEndLine + + return beginOK && endOK +} + +// capturesAreSingleSegment reports whether every capturing group in re matches a +// non-empty, slash-free fragment — i.e. each parameter consumes text within +// exactly one path segment, never spanning "/" and never vanishing. +func capturesAreSingleSegment(re *syntax.Regexp) bool { + if re.Op == syntax.OpCapture && (reNodeMayMatchSlash(re) || canMatchEmpty(re)) { + return false + } + + for _, sub := range re.Sub { + if !capturesAreSingleSegment(sub) { + return false + } + } + + return true +} + +// canMatchEmpty reports whether the regexp subtree re can match the empty +// string. Such a capture would let a template segment vanish, so the template's +// segment count would no longer equal the request's and the trie walk would miss +// the route (e.g. "/{s:[a-z]*}" must match the request "/"). Unrecognized ops +// are reported as empty-matching, keeping the decision conservative. +// +//nolint:exhaustive // the default arm deliberately covers every remaining op conservatively. +func canMatchEmpty(re *syntax.Regexp) bool { + switch re.Op { + case syntax.OpLiteral: + return len(re.Rune) == 0 + case syntax.OpCharClass, syntax.OpAnyChar, syntax.OpAnyCharNotNL, syntax.OpNoMatch: + // Each of these consumes exactly one rune. + return false + case syntax.OpCapture, syntax.OpPlus: + // x+ matches empty only if x does; a capture defers to its body. + return canMatchEmpty(re.Sub[0]) + case syntax.OpRepeat: + return re.Min == 0 || canMatchEmpty(re.Sub[0]) + case syntax.OpConcat: + return allCanMatchEmpty(re.Sub) + case syntax.OpAlternate: + return anyCanMatchEmpty(re.Sub) + default: + // OpEmptyMatch, OpStar, OpQuest, anchors, word boundaries, and anything + // added to the syntax package later: assume it can match empty. + return true + } +} + +// allCanMatchEmpty reports whether every branch can match empty — a concatenation +// matches empty only if all of its parts do. +func allCanMatchEmpty(subs []*syntax.Regexp) bool { + for _, sub := range subs { + if !canMatchEmpty(sub) { + return false + } + } + + return true +} + +// anyCanMatchEmpty reports whether any branch can match empty — an alternation +// matches empty if at least one of its arms does. +func anyCanMatchEmpty(subs []*syntax.Regexp) bool { + for _, sub := range subs { + if canMatchEmpty(sub) { + return true + } + } + + return false +} + +// reNodeMayMatchSlash reports whether the regexp subtree re can match a "/". +func reNodeMayMatchSlash(re *syntax.Regexp) bool { + if opEmitsSlash(re) { + return true + } + + for _, sub := range re.Sub { + if reNodeMayMatchSlash(sub) { + return true + } + } + + return false +} + +// opEmitsSlash reports whether this single regexp node (ignoring its children) +// can itself contribute a "/". Only literal/char-class/any-char ops can; every +// other op is purely structural and defers to its children. +// +//nolint:exhaustive // only the char-producing ops can introduce a "/"; the rest are structural. +func opEmitsSlash(re *syntax.Regexp) bool { + switch re.Op { + case syntax.OpAnyChar, syntax.OpAnyCharNotNL: + // "." matches "/" in Go's regexp (only newline is excluded). + return true + case syntax.OpLiteral: + for _, r := range re.Rune { + if r == '/' { + return true + } + } + case syntax.OpCharClass: + // Rune holds inclusive [lo, hi] range pairs. + for i := 0; i+1 < len(re.Rune); i += 2 { + if re.Rune[i] <= '/' && '/' <= re.Rune[i+1] { + return true + } + } + } + + return false +} + +func (idx *routeIndex) insert(tpl string, e *routeEntry) { + cur := idx.root + + for _, seg := range pathSegments(tpl) { + if segmentHasParam(seg) { + if cur.paramChild == nil { + cur.paramChild = newTrieNode() + } + + cur = cur.paramChild + + continue + } + + next, ok := cur.children[seg] + if !ok { + next = newTrieNode() + cur.children[seg] = next + } + + cur = next + } + + cur.routes = append(cur.routes, e) +} + +// collect gathers every route whose template structurally lines up with path +// by exploring both the literal and the parameter branch at each segment +// (backtracking). This is O(path length × small branching), never O(route +// count). A route the trie omits here could not have matched path anyway, so +// omitting it does not change the final result. +func (n *trieNode) collect(rest string, out *[]*routeEntry) { + if rest == "" { + *out = append(*out, n.routes...) + + return + } + + var seg, tail string + if i := strings.IndexByte(rest, '/'); i >= 0 { + seg, tail = rest[:i], rest[i+1:] + } else { + seg, tail = rest, "" + } + + if child, ok := n.children[seg]; ok { + child.collect(tail, out) + } + + if n.paramChild != nil { + n.paramChild.collect(tail, out) + } +} + +// match narrows candidates via the trie, adds the fallback routes, orders the +// whole set by registration order, and returns mux's own match for the first +// candidate that fully matches — identical to what stock mux would pick, only +// without scanning every registered route. +// +// It reports true and fills rm on a full match, and false otherwise — nothing +// more. It deliberately does NOT classify a non-match as 404 or 405: that +// distinction depends on state which routes that do not match the request path +// still mutate (see serveTrie), so it cannot be derived from the narrowed +// candidate set. serveTrie hands every non-match to mux's own ServeHTTP, which +// decides it by the full scan, exactly as it would without the index. +func (idx *routeIndex) match(req *http.Request, rm *mux.RouteMatch) bool { + var buf [12]*routeEntry + + cands := buf[:0] + idx.root.collect(strings.Trim(req.URL.Path, "/"), &cands) + + // mux matches the escaped path when the router is in UseEncodedPath mode, + // where the escaped and decoded forms can split into different segments. + // That flag is not readable from here, so when the two forms differ, walk + // both and take the union. Over-producing candidates is always safe (mux + // filters them); missing one would not be. + if esc := req.URL.EscapedPath(); esc != req.URL.Path { + idx.root.collect(strings.Trim(esc, "/"), &cands) + } + + if len(idx.fallback) > 0 { + cands = append(cands, idx.fallback...) + } + + sortByRegistrationOrder(cands) + + for _, e := range cands { + // Match writes into rm directly; reset it between candidates so a + // failed attempt cannot leak state into the next one. This avoids a + // per-candidate RouteMatch allocation. + *rm = mux.RouteMatch{} + + // Mirror mux.Router.ServeHTTP: the first route whose Match succeeds + // wins, whatever MatchErr says. A subrouter with its own + // NotFoundHandler reports a successful match with MatchErr set to + // ErrNotFound and its handler in rm.Handler, and mux serves exactly + // that; requiring MatchErr == nil here would skip it and fall through + // to an unrelated route. + if e.route.Match(req, rm) { + return true + } + } + + // Leave nothing behind for the caller to misread as a partial verdict: the + // request is going to mux, which starts from a clean RouteMatch of its own. + *rm = mux.RouteMatch{} + + return false +} + +// sortByRegistrationOrder orders candidates by their registration index so the +// scan reproduces mux's first-registered-wins semantics. Insertion sort: the +// candidate set is tiny (a handful at most) and, unlike sort.Slice, this +// allocates nothing — sort.Slice's reflect-based swapper would otherwise cost an +// allocation on every single request, since GoFr's PathPrefix("/") catch-all +// means the set is essentially never of length one. +func sortByRegistrationOrder(cands []*routeEntry) { + for i := 1; i < len(cands); i++ { + for j := i; j > 0 && cands[j].order < cands[j-1].order; j-- { + cands[j], cands[j-1] = cands[j-1], cands[j] + } + } +} + +// pathSegments splits a path into its non-empty segments. "/" yields no +// segments; "/users/{id}" yields ["users", "{id}"]. +func pathSegments(p string) []string { + p = strings.Trim(p, "/") + if p == "" { + return nil + } + + return strings.Split(p, "/") +} + +// segmentHasParam reports whether a template segment contains a mux parameter — +// a whole-segment param ("{id}", "{id:[0-9]+}") or a param embedded with literal +// text ("{name}.txt", "user-{id}", "{a}.{b}"). Any such segment matches exactly +// one path segment, because slash-spanning params are excluded upstream (see +// exactPathTemplate) and routed to the fallback list. It is therefore indexed as +// a single parameter slot in the trie, and mux's Route.Match enforces the exact +// intra-segment pattern — the trie only needs to produce the route as a +// candidate, never to decide it. +func segmentHasParam(seg string) bool { + return strings.Contains(seg, "{") +} diff --git a/pkg/gofr/http/trie_router_test.go b/pkg/gofr/http/trie_router_test.go new file mode 100644 index 0000000000..a935d98b09 --- /dev/null +++ b/pkg/gofr/http/trie_router_test.go @@ -0,0 +1,749 @@ +package http + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "sort" + "testing" + + "github.com/gorilla/mux" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// This file is the differential ("characterization") gate for the trie router. +// The discipline: register an identical route set on the default mux router and +// on the trie router, replay the same requests through both, and require the +// observable outcome — status code, body, and extracted path params — to be +// byte-for-byte identical. mux is the oracle; any divergence is either a bug to +// fix or a consciously documented intentional change. + +// routeDef is one route registration used by both routers. +type routeDef struct { + method string + pattern string +} + +// echoHandler reports, as JSON, exactly what the router resolved for a request: +// the path params (via mux.Vars, which must work under both routers) and the +// route template. Comparing these across routers proves they matched the same +// route with the same variables. +func echoHandler(tag string) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + + keys := make([]string, 0, len(vars)) + for k := range vars { + keys = append(keys, k) + } + + sort.Strings(keys) + + ordered := make([][2]string, 0, len(keys)) + for _, k := range keys { + ordered = append(ordered, [2]string{k, vars[k]}) + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(map[string]any{"tag": tag, "vars": ordered}) + }) +} + +// buildRouter constructs a Router in the requested mode with the given routes. +func buildRouter(routes []routeDef, useTrie bool) *Router { + r := NewRouter() + r.useTrie = useTrie + + for _, rd := range routes { + r.Add(rd.method, rd.pattern, echoHandler(rd.method+" "+rd.pattern)) + } + + return r +} + +// serve runs one request and returns the response status and body. +func serve(router *Router, method, target string) (status int, body string) { + req := httptest.NewRequestWithContext(context.Background(), method, target, http.NoBody) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + return rec.Code, rec.Body.String() +} + +type reqCase struct { + method string + target string +} + +// runDifferential registers routes on both a mux and a trie router and asserts +// every request yields an identical (status, body) from both. +func runDifferential(t *testing.T, name string, routes []routeDef, cases []reqCase) { + t.Helper() + + muxR := buildRouter(routes, false) + trieR := buildRouter(routes, true) + + for _, c := range cases { + t.Run(fmt.Sprintf("%s/%s_%s", name, c.method, c.target), func(t *testing.T) { + muxStatus, muxBody := serve(muxR, c.method, c.target) + trieStatus, trieBody := serve(trieR, c.method, c.target) + + require.Equalf(t, muxStatus, trieStatus, + "status differs for %s %s (mux=%d trie=%d)", c.method, c.target, muxStatus, trieStatus) + assert.Equalf(t, muxBody, trieBody, + "body differs for %s %s", c.method, c.target) + }) + } +} + +func TestTrieDifferential_StaticAndParams(t *testing.T) { + routes := []routeDef{ + {http.MethodGet, "/users"}, + {http.MethodGet, "/users/me"}, // static beats param when registered first + {http.MethodGet, "/users/{id}"}, // single param + {http.MethodGet, "/users/{id}/posts"}, // nested static after param + {http.MethodGet, "/orgs/{org}/repos/{repo}"}, + {http.MethodPost, "/users/{id}"}, // same path, different method + {http.MethodGet, "/items/{id:[0-9]+}"}, + } + + cases := []reqCase{ + {http.MethodGet, "/users"}, + {http.MethodGet, "/users/me"}, // must hit the static route + {http.MethodGet, "/users/42"}, // must hit {id} + {http.MethodGet, "/users/42/posts"}, // nested + {http.MethodGet, "/orgs/gofr/repos/x"}, + {http.MethodPost, "/users/42"}, // POST variant + {http.MethodGet, "/items/123"}, // regex passes + {http.MethodGet, "/items/abc"}, // regex fails -> 404 + {http.MethodDelete, "/users/42"}, // no DELETE -> 405 + {http.MethodGet, "/nonexistent"}, // unknown -> 404 + {http.MethodGet, "/users/42/unknown"}, // partial depth -> 404 + } + + runDifferential(t, "static_params", routes, cases) +} + +func TestTrieDifferential_OverlapOrder(t *testing.T) { + // Param registered BEFORE the static — mux tries in registration order, so + // /cfg/all is served by {key}. The trie must reproduce that exactly. + routes := []routeDef{ + {http.MethodGet, "/cfg/{key}"}, + {http.MethodGet, "/cfg/all"}, + } + + cases := []reqCase{ + {http.MethodGet, "/cfg/all"}, // {key} wins because registered first + {http.MethodGet, "/cfg/x"}, + } + + runDifferential(t, "overlap_order", routes, cases) +} + +func TestTrieDifferential_TrailingSlashAndNormalization(t *testing.T) { + routes := []routeDef{ + {http.MethodGet, "/a/b"}, + {http.MethodGet, "/"}, + {http.MethodGet, "/x/{id}"}, + } + + cases := []reqCase{ + {http.MethodGet, "/a/b"}, + {http.MethodGet, "/a/b/"}, // trailing slash normalized + {http.MethodGet, "//a//b"}, // double slashes normalized + {http.MethodGet, "/a/./b"}, // dot segment + {http.MethodGet, "/a/c/../b"}, // dot-dot segment + {http.MethodGet, "/"}, + {http.MethodGet, "/x/7?a=1&b=2"}, // query ignored, vars intact + } + + runDifferential(t, "normalization", routes, cases) +} + +// TestTrieDifferential_SlashSpanningParams covers routes whose param regex can +// match "/" and therefore span multiple request-path segments — catch-all file +// paths and proxy passthroughs, a standard mux idiom. These must go to the +// fallback list so mux's own matcher resolves them; the trie must not silently +// drop them. +func TestTrieDifferential_SlashSpanningParams(t *testing.T) { + routes := []routeDef{ + {http.MethodGet, "/files/{path:.*}"}, // classic catch-all + {http.MethodGet, "/proxy/{rest:.+}"}, // non-empty catch-all + {http.MethodGet, "/mix/{a}/{b:[a-z/]+}"}, // slash allowed in a class + {http.MethodGet, "/items/{id:[0-9]+}"}, // slash-free regex: stays fast in trie + {http.MethodGet, "/plain/{name}"}, // plain param: single segment + } + + cases := []reqCase{ + {http.MethodGet, "/files/a/b/c.txt"}, // spans 3 segments -> vars{path:a/b/c.txt} + {http.MethodGet, "/files/single"}, + {http.MethodGet, "/files/"}, // empty catch-all (.* matches "") + {http.MethodGet, "/proxy/x/y"}, + {http.MethodGet, "/proxy/"}, // .+ needs >=1 char -> 404 in both + {http.MethodGet, "/mix/one/a/b/c"}, // {b} spans a/b/c + {http.MethodGet, "/items/42"}, // regex passes + {http.MethodGet, "/items/4/2"}, // extra segment -> 404 in both + {http.MethodGet, "/plain/bob"}, + {http.MethodGet, "/plain/bob/extra"}, // extra segment -> 404 in both + } + + runDifferential(t, "slash_spanning", routes, cases) +} + +// mixedSegmentRoutes exercises every single-segment pattern shape mux supports: +// a param with a literal suffix, prefix, or both; regex params with an affix; +// adjacent and multi params; and a pure literal sharing a position with a mixed +// one. All match exactly one path segment, so the trie indexes each as a param +// slot and mux validates the intra-segment pattern. +func mixedSegmentRoutes() []routeDef { + return []routeDef{ + {http.MethodGet, "/files/{name}.txt"}, // literal suffix + {http.MethodGet, "/files/pinned.txt"}, // pure literal at the SAME position + {http.MethodGet, "/user-{id}"}, // literal prefix + {http.MethodGet, "/pre-{x}-post"}, // literal on both sides + {http.MethodGet, "/v{ver}/x"}, // mixed, then a static segment + {http.MethodGet, "/{a}.{b}"}, // two params + a literal separator + {http.MethodGet, "/{lang}-{region}"}, // two params, whole-segment braces + {http.MethodGet, "/img/{id:[0-9]+}.png"}, // regex param + literal suffix + {http.MethodGet, "/mix/{y:[a-z]+}.json/tail"}, // regex+suffix, then static + {http.MethodGet, "/plain/{id}"}, // control: pure param + } +} + +// TestTrieDifferential_MixedLiteralParamSegments covers segments that mix a +// literal with a parameter. These match exactly one path segment, so the trie +// indexes them as a parameter slot (mux validates the exact pattern) — they are +// NOT deferred to the fallback list. Verified byte-identical to mux across a +// battery of affix / regex / adjacency / overlap / negative cases. +func TestTrieDifferential_MixedLiteralParamSegments(t *testing.T) { + cases := []reqCase{ + {http.MethodGet, "/files/report.txt"}, // vars{name:report} + {http.MethodGet, "/files/report.csv"}, // wrong suffix -> 404 both + {http.MethodGet, "/files/pinned.txt"}, // literal wins by registration order + {http.MethodGet, "/files/a.b.txt"}, // dots inside the param value + {http.MethodGet, "/user-42"}, // vars{id:42} + {http.MethodGet, "/user-"}, // empty param -> 404 both + {http.MethodGet, "/admin-42"}, // wrong prefix -> 404 both + {http.MethodGet, "/pre-mid-post"}, // vars{x:mid} + {http.MethodGet, "/v2/x"}, // vars{ver:2} + {http.MethodGet, "/v/x"}, // empty param -> 404 both + {http.MethodGet, "/x.y"}, // vars{a:x,b:y} + {http.MethodGet, "/en-US"}, // vars{lang:en,region:US} + {http.MethodGet, "/img/123.png"}, // regex passes + {http.MethodGet, "/img/12a.png"}, // regex fails -> 404 both + {http.MethodGet, "/mix/abc.json/tail"}, + {http.MethodGet, "/mix/ab1.json/tail"}, // regex fails -> 404 both + {http.MethodGet, "/plain/9"}, + {http.MethodGet, "/plain/9/extra"}, // extra segment -> 404 both + } + + runDifferential(t, "mixed_seg", mixedSegmentRoutes(), cases) +} + +// TestTrieRouter_MixedSegmentsAreIndexed asserts the mixed/param routes are +// actually trie-indexed (not sitting in the linearly-scanned fallback list), so +// the O(path) property holds for them too. +func TestTrieRouter_MixedSegmentsAreIndexed(t *testing.T) { + r := buildRouter(mixedSegmentRoutes(), true) + + r.buildIdx.Do(func() { + idx := newRouteIndex() + idx.build(&r.Router) + r.idx = idx + }) + + assert.Empty(t, r.idx.fallback, "all slash-free mixed/param routes must be trie-indexed, not in fallback") +} + +// TestTrieDifferential_MethodMismatchParity pins the case that used to be the +// one documented divergence between the routers, and now is not. +// +// mux's 404-vs-405 for a wrong-method request is registration-order dependent. +// In gorilla/mux's Route.Match, a matcher that SUCCEEDS clears any +// ErrMethodMismatch a previous route left behind, and GoFr registers routes as +// Methods(m).Path(p) — method matcher first. So below, the later GET /a/b clears +// the mismatch recorded by POST /a/{id} purely because its method matches, even +// though its path does not, and mux answers 404. +// +// That makes the 405 decision depend on routes whose path does not match the +// request — precisely the routes the trie exists to skip — so no amount of +// bookkeeping over the narrowed candidate set can reproduce it. serveTrie +// therefore does not try: it hands every non-match to mux's own ServeHTTP, which +// resolves it by the full scan. The two routers agree here by construction, not +// by coincidence. +func TestTrieDifferential_MethodMismatchParity(t *testing.T) { + routes := []routeDef{ + {http.MethodPost, "/a/{id}"}, // only POST on this path + {http.MethodGet, "/a/b"}, // a later, non-overlapping path + } + + muxR := buildRouter(routes, false) + trieR := buildRouter(routes, true) + + // GET /a/xyz: the path matches /a/{id}, the method does not. + muxStatus, _ := serve(muxR, http.MethodGet, "/a/xyz") + trieStatus, _ := serve(trieR, http.MethodGet, "/a/xyz") + + assert.Equal(t, http.StatusNotFound, muxStatus, "mux baseline: registration-order 404") + assert.Equal(t, muxStatus, trieStatus, "trie must reproduce mux's order-dependent status, not a 405") +} + +// TestTrieRouter_UnmatchedFallsBackToMux asserts the safety property that the +// delegation buys: a route missing from the trie index is still served. +// +// The index is normally built by serveTrie from the router's own routes, so this +// installs a deliberately blind one — every route diverted to neither the trie +// nor the fallback list — to simulate the worst outcome a classifier bug could +// produce. Without the delegation this request 404s even though the route is +// registered and matches; with it, mux's full scan finds the route and serves it, +// so a classifier bug costs speed rather than correctness. +func TestTrieRouter_UnmatchedFallsBackToMux(t *testing.T) { + r := NewRouter() + r.useTrie = true + r.Add(http.MethodGet, "/users/{id}", echoHandler("real-handler")) + + // Pre-seed an empty index so the lazy build in serveTrie is skipped and the + // trie can match nothing at all. + r.buildIdx.Do(func() { r.idx = newRouteIndex() }) + + status, body := serve(r, http.MethodGet, "/users/42") + + require.Equal(t, http.StatusOK, status, "an unindexed but registered route must still be served by mux") + assert.Contains(t, body, "real-handler") + assert.Contains(t, body, `"42"`, "mux must populate the path params it always would") +} + +// TestTrieRouter_SubrouterMethodNotAllowedHandler is the other half of the match-with-error family, +// and it is deliberately the OPPOSITE expectation from the NotFoundHandler case above: here the +// middleware chain MUST run. +// +// A subrouter's MethodNotAllowedHandler also reports a successful match, but mux clears +// ErrMethodMismatch the moment one of the route's matchers succeeds (the else arm of the matcher loop +// in gorilla/mux route.go), so the match arrives with a nil MatchErr and mux builds its chain. A +// blanket "skip the chain whenever the handler came from an error path" would be wrong here, which is +// why the guard keys on MatchErr rather than on the kind of handler. +func TestTrieRouter_SubrouterMethodNotAllowedHandler(t *testing.T) { + build := func(useTrie bool, mwRuns *int) *Router { + r := NewRouter() + r.useTrie = useTrie + + sub := r.Router.PathPrefix("/mna").Subrouter() + sub.MethodNotAllowedHandler = echoHandler("SUB405") + sub.Path("/only-post").Methods(http.MethodPost).Handler(echoHandler("mna-post")) + + r.UseMiddleware(func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + *mwRuns++ + + next.ServeHTTP(w, req) + }) + }) + + return r + } + + for _, c := range []struct { + method, target string + }{ + {http.MethodPost, "/mna/only-post"}, // the real route + {http.MethodDelete, "/mna/only-post"}, // wrong method -> the subrouter's 405 handler + {http.MethodGet, "/mna/absent"}, // no inner route at all + } { + var muxN, trieN int + + muxStatus, muxBody := serve(build(false, &muxN), c.method, c.target) + trieStatus, trieBody := serve(build(true, &trieN), c.method, c.target) + + require.Equalf(t, muxStatus, trieStatus, "status differs for %s %s", c.method, c.target) + assert.Equalf(t, muxBody, trieBody, "handler differs for %s %s", c.method, c.target) + assert.Equalf(t, muxN, trieN, "middleware count differs for %s %s (mux=%d trie=%d)", + c.method, c.target, muxN, trieN) + } +} + +// TestTrieDifferential_MiddlewareParity asserts the middleware chain runs the +// same number of times under both routers for matched, 404, and 405 requests. +// mux applies its chain only on a successful match (never around NotFound / +// MethodNotAllowed), so the trie must not run middleware on unmatched requests +// either — otherwise 404s would be logged/measured and CORS-answered, and the +// metrics path label would be populated from a raw unbounded path. +func TestTrieDifferential_MiddlewareParity(t *testing.T) { + build := func(useTrie bool, counter *int) *Router { + rt := NewRouter() + rt.useTrie = useTrie + rt.Add(http.MethodGet, "/hit", echoHandler("hit")) + rt.Add(http.MethodPost, "/only-post", echoHandler("op")) + rt.UseMiddleware(func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + *counter++ + + next.ServeHTTP(w, r) + }) + }) + + return rt + } + + cases := []struct { + name string + method, target string + }{ + {"matched", http.MethodGet, "/hit"}, + {"not_found", http.MethodGet, "/missing"}, + {"method_not_allowed", http.MethodGet, "/only-post"}, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + var muxN, trieN int + + serve(build(false, &muxN), c.method, c.target) + serve(build(true, &trieN), c.method, c.target) + + assert.Equalf(t, muxN, trieN, + "middleware invocation count differs for %s %s (mux=%d trie=%d)", + c.method, c.target, muxN, trieN) + }) + } +} + +func TestTrieDifferential_MethodsAndFallback(t *testing.T) { + // A header-constrained route (fallback) plus a plain route on the same path + // exercises registration-order fidelity across the trie/fallback boundary. + r := func(useTrie bool) *Router { + rt := NewRouter() + rt.useTrie = useTrie + rt.Router.NewRoute().Methods(http.MethodGet).Path("/gated"). + Headers("X-Key", "secret").Handler(echoHandler("gated-hdr")) + rt.Add(http.MethodGet, "/gated", echoHandler("gated-plain")) + + return rt + } + + muxR, trieR := r(false), r(true) + + do := func(router *Router, hdr string) (int, string) { + req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/gated", http.NoBody) + if hdr != "" { + req.Header.Set("X-Key", hdr) + } + + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + return rec.Code, rec.Body.String() + } + + for _, hdr := range []string{"secret", "wrong", ""} { + ms, mb := do(muxR, hdr) + ts, tb := do(trieR, hdr) + require.Equalf(t, ms, ts, "status differs for header %q", hdr) + assert.Equalf(t, mb, tb, "body differs for header %q", hdr) + } +} + +// benchmarkRouterMatch measures per-request route matching as the number of +// registered routes grows, for one router mode. mux scans routes linearly +// (O(n)); the trie narrows to O(path length), so its cost should stay roughly +// flat as n rises. +func benchmarkRouterMatch(b *testing.B, useTrie bool) { + b.Helper() + + handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }) + + for _, n := range []int{1, 10, 20, 50, 100, 200} { + b.Run(fmt.Sprintf("routes=%d", n), func(b *testing.B) { + r := NewRouter() + r.useTrie = useTrie + + for i := 0; i < n; i++ { + r.Add(http.MethodGet, fmt.Sprintf("/resource-%d/{id}", i), handler) + } + + // Every GoFr app registers a PathPrefix("/") catch-all, which lands + // in the fallback list and is therefore scanned on every request. + // Include it so the numbers reflect a real route table. + r.Router.NewRoute().PathPrefix("/").Handler(handler) + + // Match the MIDDLE route. mux's scan cost is linear in registration + // position, so hitting the last route measures its worst case rather + // than what real traffic sees; the middle route is the average case. + // The trie is unaffected by position either way. + target := fmt.Sprintf("/resource-%d/42", n/2) + req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, target, http.NoBody) + w := httptest.NewRecorder() + + b.ReportAllocs() + b.ResetTimer() + + for i := 0; i < b.N; i++ { + r.ServeHTTP(w, req) + } + }) + } +} + +// BenchmarkRouterMatchMux is the O(n) baseline (default router). +func BenchmarkRouterMatchMux(b *testing.B) { benchmarkRouterMatch(b, false) } + +// BenchmarkRouterMatchTrie is the trie matcher (GOFR_ROUTER=trie); its cost +// should stay flat as the route count grows. +func BenchmarkRouterMatchTrie(b *testing.B) { benchmarkRouterMatch(b, true) } + +// This file pins the router edge cases found by adversarial review. Each test +// names the shape that previously diverged from mux, so a regression is +// self-describing. + +// TestTrieDifferential_EmptyMatchingParams covers params whose regex can match +// the empty string ("{s:[a-z]*}"). Such a template segment can vanish, so the +// template's segment count no longer equals the request's and a segment-by- +// segment walk cannot locate it — the route must go to the fallback list. +// +// This previously produced a SILENT WRONG ROUTE in the real GoFr shape: with the +// PathPrefix("/") catch-all also registered, a request for "/" returned 200 from +// both routers but ran the catch-all under the trie instead of the user handler. +func TestTrieDifferential_EmptyMatchingParams(t *testing.T) { + routes := []routeDef{ + {http.MethodGet, "/{s:[a-z]*}"}, // may match "" + {http.MethodGet, "/n/{n:[0-9]*}"}, // may match "" + {http.MethodGet, "/o/{a:(?:x)?}"}, // optional group + {http.MethodGet, "/r/{l:[a-z]{0,2}}"}, + {http.MethodGet, "/ok/{id}"}, // control: cannot match "" + } + + cases := []reqCase{ + {http.MethodGet, "/"}, // the empty-match case + {http.MethodGet, "/abc"}, // non-empty + {http.MethodGet, "/n/"}, // trailing empty after normalization + {http.MethodGet, "/n/123"}, // + {http.MethodGet, "/o/"}, // + {http.MethodGet, "/o/x"}, // + {http.MethodGet, "/r/ab"}, // + {http.MethodGet, "/ok/7"}, // control + } + + runDifferential(t, "empty_param", routes, cases) +} + +// TestTrieDifferential_EmptyParamWithCatchAll reproduces the silent-wrong-route +// shape directly: the user route and GoFr's default PathPrefix("/") catch-all +// both match "/", so the status is 200 either way and only the BODY reveals +// which handler ran. +func TestTrieDifferential_EmptyParamWithCatchAll(t *testing.T) { + build := func(useTrie bool) *Router { + r := NewRouter() + r.useTrie = useTrie + r.Add(http.MethodGet, "/{s:[a-z]*}", echoHandler("user-handler")) + r.Router.NewRoute().PathPrefix("/").Handler(echoHandler("catch-all")) + + return r + } + + _, muxBody := serve(build(false), http.MethodGet, "/") + _, trieBody := serve(build(true), http.MethodGet, "/") + + assert.Equal(t, muxBody, trieBody, "the same handler must run under both routers") +} + +// TestTrieRouter_PrefixEndingInLiteralDollar covers a PathPrefix whose template +// ends in a literal "$". mux QuoteMeta-escapes it, so the UNANCHORED prefix +// regexp still ends in the byte "$" — a naive HasSuffix(rx, "$") check would +// wrongly treat it as an exact path and index it. +func TestTrieRouter_PrefixEndingInLiteralDollar(t *testing.T) { + build := func(useTrie bool) *Router { + r := NewRouter() + r.useTrie = useTrie + r.Router.NewRoute().PathPrefix("/p$").Handler(echoHandler("prefix")) + + return r + } + + muxStatus, muxBody := serve(build(false), http.MethodGet, "/p$/q") + trieStatus, trieBody := serve(build(true), http.MethodGet, "/p$/q") + + require.Equal(t, muxStatus, trieStatus, "prefix route ending in a literal $ must still match") + assert.Equal(t, muxBody, trieBody) +} + +// TestTrieRouter_SubrouterMiddlewareRuns guards the security-relevant case: a +// subrouter's own middleware must run under the trie router too. It previously +// did not when the parent prefix was misclassified as an exact path, so auth or +// CORS registered via Subrouter().Use() was silently skipped. +func TestTrieRouter_SubrouterMiddlewareRuns(t *testing.T) { + build := func(useTrie bool) *Router { + r := NewRouter() + r.useTrie = useTrie + + sub := r.Router.PathPrefix("/api$").Subrouter() + sub.Use(func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + w.Header().Set("X-Sub", "yes") + next.ServeHTTP(w, req) + }) + }) + sub.Path("/users").Handler(echoHandler("sub-users")) + + return r + } + + do := func(router *Router) (int, string) { + req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/api$/users", http.NoBody) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + return rec.Code, rec.Header().Get("X-Sub") + } + + muxCode, muxHdr := do(build(false)) + trieCode, trieHdr := do(build(true)) + + require.Equal(t, muxCode, trieCode) + assert.Equal(t, muxHdr, trieHdr, "subrouter middleware must run under both routers") +} + +// TestTrieRouter_SubrouterNotFoundHandler covers a subrouter carrying its own +// NotFoundHandler. mux reports a SUCCESSFUL match with MatchErr == ErrNotFound +// and the subrouter's handler in the match; requiring MatchErr == nil would skip +// it and fall through to an unrelated route. +func TestTrieRouter_SubrouterNotFoundHandler(t *testing.T) { + build := func(useTrie bool, mwRuns *int) *Router { + r := NewRouter() + r.useTrie = useTrie + + sub := r.Router.PathPrefix("/api").Subrouter() + sub.NotFoundHandler = echoHandler("SUB404") + sub.Path("/users").Handler(echoHandler("sub-users")) + + r.Add(http.MethodGet, "/api/other", echoHandler("top-other")) + + r.UseMiddleware(func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + *mwRuns++ + + next.ServeHTTP(w, req) + }) + }) + + return r + } + + // The middleware count is asserted, not just the status and body. This case is why: a subrouter + // NotFoundHandler answers 200 through mux and through the trie alike, so status and body agree + // while the chain does not. mux builds its chain only when MatchErr == nil, and this match carries + // ErrNotFound, so mux runs NO middleware here. The trie ran it, which meant such a request was + // logged, traced, metered and CORS-answered when mux would not have -- and with no path template + // on a PathPrefix route, the metrics label fell back to the raw request path. + for _, target := range []string{"/api/other", "/api/zzz", "/api/users"} { + var muxN, trieN int + + muxStatus, muxBody := serve(build(false, &muxN), http.MethodGet, target) + trieStatus, trieBody := serve(build(true, &trieN), http.MethodGet, target) + + require.Equalf(t, muxStatus, trieStatus, "status differs for %s", target) + assert.Equalf(t, muxBody, trieBody, "handler differs for %s", target) + assert.Equalf(t, muxN, trieN, "middleware invocation count differs for %s (mux=%d trie=%d)", + target, muxN, trieN) + } +} + +// TestTrieRouter_UseEncodedPath covers a router in UseEncodedPath mode, where +// mux matches the ESCAPED path. The decoded and escaped forms can split into +// different segment counts, so the trie walks both and unions the candidates. +func TestTrieRouter_UseEncodedPath(t *testing.T) { + build := func(useTrie bool) *Router { + r := NewRouter() + r.useTrie = useTrie + r.Router.UseEncodedPath() + r.Router.NewRoute().Methods(http.MethodGet).Path("/a/{v}").Handler(echoHandler("one")) + r.Router.NewRoute().Methods(http.MethodGet).Path("/a/{x}/{y}").Handler(echoHandler("two")) + + return r + } + + muxR, trieR := build(false), build(true) + + for _, target := range []string{"/a/x%2Fy", "/a/plain", "/a/p/q"} { + muxStatus, muxBody := serve(muxR, http.MethodGet, target) + trieStatus, trieBody := serve(trieR, http.MethodGet, target) + + require.Equalf(t, muxStatus, trieStatus, "status differs for %s", target) + assert.Equalf(t, muxBody, trieBody, "handler/vars differ for %s", target) + } +} + +// TestTrieRouter_IndexInvariant is the structural gate behind every other test: +// for every request, no route that mux would match may be absent from the +// candidate set the trie produces. Over-producing candidates is safe (mux +// filters them); dropping one is the failure mode that causes silent 404s and +// wrong handlers. This asserts the invariant directly rather than relying on a +// particular request happening to expose a gap. +func TestTrieRouter_IndexInvariant(t *testing.T) { + templates := []string{ + "/", "/users", "/users/{id}", "/users/{id}/posts", "/{a}/{b}", + "/files/{name}.txt", "/user-{id}", "/{a}.{b}", "/img/{id:[0-9]+}.png", + "/files/{path:.*}", "/proxy/{rest:.+}", "/opt/{s:[a-z]*}", "/n/{n:[0-9]*}", + "/static/x", "/deep/a/b/c/d", + } + + r := NewRouter() + r.useTrie = true + + for i, tpl := range templates { + r.Add(http.MethodGet, tpl, echoHandler(fmt.Sprintf("h%d", i))) + } + + r.Router.NewRoute().PathPrefix("/").Handler(echoHandler("catch-all")) + + idx := newRouteIndex() + idx.build(&r.Router) + + paths := []string{ + "/", "/users", "/users/42", "/users/42/posts", "/a/b", "/files/x.txt", + "/user-9", "/x.y", "/img/12.png", "/files/a/b/c", "/proxy/z", "/opt/", + "/opt/abc", "/n/", "/n/5", "/static/x", "/deep/a/b/c/d", "/unknown/path", + } + + for _, p := range paths { + req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, p, http.NoBody) + normalizePath(req) + + // Every route mux itself would match must be among the candidates. + cands := make([]*routeEntry, 0, len(idx.fallback)) + + idx.root.collect(pathTrimForTest(req.URL.Path), &cands) + cands = append(cands, idx.fallback...) + + inCandidates := make(map[*mux.Route]bool, len(cands)) + for _, c := range cands { + inCandidates[c.route] = true + } + + _ = r.Router.Walk(func(route *mux.Route, _ *mux.Router, _ []*mux.Route) error { + var m mux.RouteMatch + if route.Match(req, &m) { + assert.Truef(t, inCandidates[route], + "path %q: mux matches a route the trie never offered as a candidate", p) + } + + return nil + }) + } +} + +func pathTrimForTest(p string) string { + for p != "" && p[0] == '/' { + p = p[1:] + } + + for p != "" && p[len(p)-1] == '/' { + p = p[:len(p)-1] + } + + return p +} diff --git a/pkg/gofr/http_server.go b/pkg/gofr/http_server.go index 71a437e730..7b201178b8 100644 --- a/pkg/gofr/http_server.go +++ b/pkg/gofr/http_server.go @@ -6,12 +6,14 @@ import ( "fmt" "net/http" "os" + "strings" "sync" "time" "gofr.dev/pkg/gofr/container" gofrHTTP "gofr.dev/pkg/gofr/http" "gofr.dev/pkg/gofr/http/middleware" + "gofr.dev/pkg/gofr/logging" "gofr.dev/pkg/gofr/websocket" ) @@ -31,8 +33,34 @@ var ( errInvalidKeyFile = errors.New("invalid key file") ) +// logRouterChoice reports the route matcher the router resolved to. +// +// It stays quiet for the default, which every service gets and nobody needs told +// about. It speaks up for the two cases that are worth a line: the opt-in matcher +// being active, and a GOFR_ROUTER value that was not understood — the latter +// falls back to mux, which looks exactly like never having set the variable, so a +// typo would otherwise cost the opt-in with nothing said. +func logRouterChoice(logger logging.Logger, r *gofrHTTP.Router) { + requested := os.Getenv(gofrHTTP.RouterEnvVar) + if requested == "" { + return + } + + if !strings.EqualFold(requested, r.Matcher()) { + logger.Warnf("unrecognized %s value %q, using the %q router; valid values are %q and %q", + gofrHTTP.RouterEnvVar, requested, r.Matcher(), gofrHTTP.MatcherMux, gofrHTTP.MatcherTrie) + + return + } + + logger.Infof("HTTP route matcher: %s", r.Matcher()) +} + func newHTTPServer(c *container.Container, port int, middlewareConfigs middleware.Config) *httpServer { r := gofrHTTP.NewRouter() + + logRouterChoice(c.Logger, r) + wsManager := websocket.New() r.Use( diff --git a/pkg/gofr/http_server_test.go b/pkg/gofr/http_server_test.go index a332edd634..1d9a70487d 100644 --- a/pkg/gofr/http_server_test.go +++ b/pkg/gofr/http_server_test.go @@ -456,3 +456,65 @@ func BenchmarkRequest_FullChain_SDK(b *testing.B) { h.ServeHTTP(w, req) } } + +// TestLogRouterChoice covers the three shapes GOFR_ROUTER can take at startup. +// The unrecognized case is the one that earns the log: it falls back to mux, +// which is indistinguishable from leaving the variable unset, so without a +// warning a typo costs the opt-in silently. +func TestLogRouterChoice(t *testing.T) { + // The logger emits JSON, so quotes inside a message come back escaped — want + // is a fragment chosen to survive that. + cases := []struct { + name string + env string + want string + wantLevel string + }{ + {"unset stays quiet", "", "", ""}, + {"trie is announced", gofrHTTP.MatcherTrie, "HTTP route matcher: trie", "INFO"}, + {"typo is warned about", "tri", "unrecognized GOFR_ROUTER value", "WARN"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + // Always set it, empty included: the suite itself may be run with + // GOFR_ROUTER exported, and the "unset" case has to mean unset. + t.Setenv(gofrHTTP.RouterEnvVar, tc.env) + + logs := testutil.StdoutOutputForFunc(func() { + c := container.NewContainer(config.NewMockConfig(map[string]string{"LOG_LEVEL": "INFO"})) + logRouterChoice(c.Logger, gofrHTTP.NewRouter()) + }) + + if tc.wantLevel == "" { + assert.NotContains(t, logs, "route matcher") + assert.NotContains(t, logs, gofrHTTP.RouterEnvVar) + + return + } + + assert.Contains(t, logs, tc.want) + assert.Contains(t, logs, `"level":"`+tc.wantLevel+`"`) + }) + } +} + +// TestRouter_Matcher pins the accessor the startup log reads: it must report +// what NewRouter actually resolved from the environment, including the fallback +// to mux for a value that is not understood. +func TestRouter_Matcher(t *testing.T) { + cases := map[string]string{ + "": gofrHTTP.MatcherMux, + "mux": gofrHTTP.MatcherMux, + "trie": gofrHTTP.MatcherTrie, + "TRIE": gofrHTTP.MatcherTrie, // the lookup is case-insensitive + "tri": gofrHTTP.MatcherMux, // unrecognized falls back + } + + for env, want := range cases { + t.Run("GOFR_ROUTER="+env, func(t *testing.T) { + t.Setenv(gofrHTTP.RouterEnvVar, env) + assert.Equal(t, want, gofrHTTP.NewRouter().Matcher()) + }) + } +} From 467b2ffd49ebd4c69e2f4cdd84fc3b5eddf883ee Mon Sep 17 00:00:00 2001 From: Umang Mundhra Date: Tue, 18 Aug 2026 16:48:57 +0530 Subject: [PATCH 08/20] chore(deps): consolidate minor/patch dependency updates (2026-08-18) (#3969) --- .github/workflows/typos.yml | 2 +- examples/using-s3-filestore/go.mod | 36 ++-- examples/using-s3-filestore/go.sum | 72 ++++---- go.mod | 40 ++--- go.sum | 105 ++++++----- go.work.sum | 176 +++++++++++++++++++ pkg/gofr/datasource/clickhouse/go.mod | 11 +- pkg/gofr/datasource/clickhouse/go.sum | 22 ++- pkg/gofr/datasource/cloudsql/go.mod | 30 ++-- pkg/gofr/datasource/cloudsql/go.sum | 69 +++++--- pkg/gofr/datasource/file/s3/go.mod | 36 ++-- pkg/gofr/datasource/file/s3/go.sum | 72 ++++---- pkg/gofr/datasource/file/sftp/go.mod | 2 +- pkg/gofr/datasource/file/sftp/go.sum | 4 +- pkg/gofr/datasource/kv-store/dynamodb/go.mod | 28 +-- pkg/gofr/datasource/kv-store/dynamodb/go.sum | 56 +++--- pkg/gofr/datasource/pubsub/nats/go.mod | 6 +- pkg/gofr/datasource/pubsub/nats/go.sum | 12 +- pkg/gofr/datasource/pubsub/sqs/go.mod | 28 +-- pkg/gofr/datasource/pubsub/sqs/go.sum | 56 +++--- pkg/gofr/datasource/solr/go.mod | 12 +- pkg/gofr/datasource/solr/go.sum | 36 ++-- 22 files changed, 548 insertions(+), 363 deletions(-) diff --git a/.github/workflows/typos.yml b/.github/workflows/typos.yml index 93568683c6..80d41a0a0c 100644 --- a/.github/workflows/typos.yml +++ b/.github/workflows/typos.yml @@ -11,4 +11,4 @@ jobs: - name: Checkout Code uses: actions/checkout@v7 - name: typos-action - uses: crate-ci/typos@v1.48.0 \ No newline at end of file + uses: crate-ci/typos@v1.49.0 \ No newline at end of file diff --git a/examples/using-s3-filestore/go.mod b/examples/using-s3-filestore/go.mod index d54906d2a8..d72d3f4271 100644 --- a/examples/using-s3-filestore/go.mod +++ b/examples/using-s3-filestore/go.mod @@ -3,10 +3,10 @@ module gofr.dev/examples/using-s3-filestore go 1.26.0 require ( - github.com/aws/aws-sdk-go-v2 v1.43.3 - github.com/aws/aws-sdk-go-v2/config v1.32.34 - github.com/aws/aws-sdk-go-v2/credentials v1.19.33 - github.com/aws/aws-sdk-go-v2/service/s3 v1.105.2 + github.com/aws/aws-sdk-go-v2 v1.43.6 + github.com/aws/aws-sdk-go-v2/config v1.32.37 + github.com/aws/aws-sdk-go-v2/credentials v1.19.36 + github.com/aws/aws-sdk-go-v2/service/s3 v1.107.2 github.com/stretchr/testify v1.11.1 gofr.dev v1.57.0 gofr.dev/pkg/gofr/datasource/file/s3 v0.3.0 @@ -24,20 +24,20 @@ require ( github.com/DATA-DOG/go-sqlmock v1.5.2 // indirect github.com/XSAM/otelsql v0.42.0 // indirect github.com/agnivade/levenshtein v1.2.1 // indirect - github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.14 // indirect - github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.34 // indirect - github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.34 // indirect - github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.34 // indirect - github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.35 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.15 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.23 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.34 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.31 // indirect - github.com/aws/aws-sdk-go-v2/service/signin v1.5.3 // indirect - github.com/aws/aws-sdk-go-v2/service/sso v1.33.3 // indirect - github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.3 // indirect - github.com/aws/aws-sdk-go-v2/service/sts v1.45.3 // indirect - github.com/aws/smithy-go v1.27.6 // indirect + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.18 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.37 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.37 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.37 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.38 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.17 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.30 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.37 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.38 // indirect + github.com/aws/aws-sdk-go-v2/service/signin v1.5.6 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.33.6 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.6 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.45.6 // indirect + github.com/aws/smithy-go v1.27.8 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect diff --git a/examples/using-s3-filestore/go.sum b/examples/using-s3-filestore/go.sum index 11564fae33..34a688874a 100644 --- a/examples/using-s3-filestore/go.sum +++ b/examples/using-s3-filestore/go.sum @@ -32,42 +32,42 @@ github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNg github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8= github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0 h1:jfIu9sQUG6Ig+0+Ap1h4unLjW6YQJpKZVmUzxsD4E/Q= github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE= -github.com/aws/aws-sdk-go-v2 v1.43.3 h1:XJIcfv8uDs2ukdQsoAC8/Ebu1ejxwzlayl2ZsiFns2A= -github.com/aws/aws-sdk-go-v2 v1.43.3/go.mod h1:70vwSy16txshwG+g55WkpgPKDIByzHI8ccBsOteo3bQ= -github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.14 h1:3IZY0XAJquT3aHzbkHfPzy4ACPcEjVG0x87KOwtpqGY= -github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.14/go.mod h1:zwM6veDkhGgQFqkBy+uT28AAYpLu+uFMlPl+rCg/73E= -github.com/aws/aws-sdk-go-v2/config v1.32.34 h1:o+YAizrX562nEZXaB38uYTK8RvIsvW0uuRP+e5e0Pfk= -github.com/aws/aws-sdk-go-v2/config v1.32.34/go.mod h1:wc0zYRChOniiufvdWiRVf3jgXSgbkvaD683IHHHc2ZQ= -github.com/aws/aws-sdk-go-v2/credentials v1.19.33 h1:/e5V3EWfeDiW6cuRxHsC8gbwko4/vvVYPJR2afBKFFY= -github.com/aws/aws-sdk-go-v2/credentials v1.19.33/go.mod h1:ZxAmkcyOM9beY/WO9oxp2oVPXiP3rq5N1/p4NbenJdE= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.34 h1:1EsGke6rTD2CG3j2MMVB77n6Q+FlbQWYI/dFdLWBNtM= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.34/go.mod h1:5B1Z/QbaWzqoWRzYxZfmCbDDRcvUHcfAIQw/S+KfDmc= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.34 h1:vuIfjzoeqhQMGJyOBU3t0ZEjn2jrN8Bbg1N4CgjzM5Q= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.34/go.mod h1:hP28cN4CPJLZHirdQPrZR50JcLN4ApRJP2tzG8cRlhY= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.34 h1:9faHsnqxJ1vDvB4wMZy/ajIDyz5QhllQjjc72RJpXAw= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.34/go.mod h1:Yp6nIyejpa23nzlB/LhT63KTla9Jdi06nv/HH/OkAH8= -github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.35 h1:Oe8gMKJLO5awqpa5EhAGKVnBv1s+brdWVuxM2mDa7zA= -github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.35/go.mod h1:FZevcG9cOST/FWAAUhHIchjR9fXFXFRCWodOhx+PDLA= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.15 h1:JJLBQxwY+AFwuPAi5ivGc1ChnTdUt4cXMv7e76m2c/Y= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.15/go.mod h1:lQknBIe78MVL0cQOQDlag8KGflMbMEVFx9mB6O8ENvk= -github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.23 h1:9Fjh6fi/U5JEStVZijmaMpUwE/gvBJj7x2B/PjbO9To= -github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.23/go.mod h1:iMoT2f1tClxrWAAnKCXjZQ6LOmfLrMG14wmnWpM+F14= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.34 h1:sYg4qHWLqsjp15PzX7XCOHSOgKEGoZ5vQY43VvZ1pas= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.34/go.mod h1:N58SSz3roKf1HzW5qRaOiyk6MbDLTKgLPvlTfJ90iyI= -github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.31 h1:uao4A3QZ5UmB326V6KF+qRpv9Tjz7IlnlnTbbANntlU= -github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.31/go.mod h1:I/1+z0VwL1GhQyLgkoHDlygpUZ+iTAwOQ/NsftiUL2I= -github.com/aws/aws-sdk-go-v2/service/s3 v1.105.2 h1:5C00eQYpTrgQXnp6V3P6P7zPElna3AXvlukbANE6nJI= -github.com/aws/aws-sdk-go-v2/service/s3 v1.105.2/go.mod h1:zdmCoFO/dSI7GlrwsPqFJI+WlFnSU4Tc8TJnlXrM1Do= -github.com/aws/aws-sdk-go-v2/service/signin v1.5.3 h1:togAtAmgV5IGMnQDuBDJeM8z5Y5RN6G7xeOgphWz+Yc= -github.com/aws/aws-sdk-go-v2/service/signin v1.5.3/go.mod h1:T7xKUUUvN7W3RW8UmMvKnD12xqh+Ux2gCPHPhnt64Dg= -github.com/aws/aws-sdk-go-v2/service/sso v1.33.3 h1:YjH64OUytnWZBHUtM9GMyi4ZWBiSQdEJkZuPykOIe44= -github.com/aws/aws-sdk-go-v2/service/sso v1.33.3/go.mod h1:5qoHcDZDTSJotoKk1bvVRPv1MXaL/NhfY9ng8D1g/ig= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.3 h1:A4o1di/XGaqtw6r3toSBrFX2U7mVSLqg7jo9wL4I+cU= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.3/go.mod h1:sKuKz2kHtrGVtFu34vbM3LWSA9CKD9YZUmm6e5PPqRA= -github.com/aws/aws-sdk-go-v2/service/sts v1.45.3 h1:Fi7+DiKN1+QphlajvE6FqeZ8GRbnnRul7zTdUiRpbGc= -github.com/aws/aws-sdk-go-v2/service/sts v1.45.3/go.mod h1:KCc3e27fHZUGtzpek7wZcp6dyCpGkJJo/+3PBujh/yU= -github.com/aws/smithy-go v1.27.6 h1:0zjT8jgK3jbrTT7JJ3EE6JsMhX8JTrZ+f1sEndYDXrA= -github.com/aws/smithy-go v1.27.6/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= +github.com/aws/aws-sdk-go-v2 v1.43.6 h1:RrmFcqCBxkJuf7g1axVo5krB4jM/AO8r5e5oujrgdoQ= +github.com/aws/aws-sdk-go-v2 v1.43.6/go.mod h1:tXpPM+v0D1lndmga+HqqLDIzUFJlEeR21aspVklHF00= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.18 h1:LAfOuhAH331fmOjTQpAaOlH+Ftn7RzSDJ2VFwjdMMy4= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.18/go.mod h1:4e5xhuXHx1e4U9EthvbPP1r/DIMp5c2823OL8karzcM= +github.com/aws/aws-sdk-go-v2/config v1.32.37 h1:Ljl7LOJB6ym0liuEl0+TZ3d7f5I8MEZN1Cj9PINlj/g= +github.com/aws/aws-sdk-go-v2/config v1.32.37/go.mod h1:WJ7pe7ZPpmG8Q5kKS53zeypIV4FBGACxmte8Uc6SgUc= +github.com/aws/aws-sdk-go-v2/credentials v1.19.36 h1:84s5xMme6ENYEdKG8rsbSFFg/8+lbHBeM9QYSO0gnDk= +github.com/aws/aws-sdk-go-v2/credentials v1.19.36/go.mod h1:c46BLdagDLIswjgt+GeQOslXgeS0E6wCacs5yZbxPGk= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.37 h1:b5tb+CZItBkydC7r3hTNdSO3pszG1R2EtnA+7TePQPk= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.37/go.mod h1:ZQ+6SU9X0oz6+7MUCSswv9Mjci4eaqZr21HI2RVy/yA= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.37 h1:lznzIOvvbqjfe8UAaciCRJgBgJsxuTROKlhZuXQWfv8= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.37/go.mod h1:otfkzyfQeMMLZAqX59GSXTL3o22BR/l6HFaRzzbWSqA= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.37 h1:zCEORWo0eU0gDjG+IyApE/2B+ZGG1m+GU7B263XV8ds= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.37/go.mod h1:i6c0PEl3TNOWxRbQ++KQcVenPWS/GoQeiklKhNuqzJ8= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.38 h1:A3UAuCmx7LyUcrixBTzKJYYIUZ2yTvn6ZhT8PB+7APk= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.38/go.mod h1:1PDUYG9Z+JrbbsobsAZHjWOm9QBT/djiK3QbykTL5Z4= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.17 h1:OvYZOB3qA6zvfdRFiRFRzVSiElMYrz3GdntkXZxlp1o= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.17/go.mod h1:JgR/2Ew50ACfIWau1oeMRX59tMtC0kM+PYQGEaT04cY= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.30 h1:5437eMoOwqqQpZn2XJy74mlDCuPYL81texMT3mXqgtU= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.30/go.mod h1:xfu2m3dOpvW8lj98wQYa8V9ku/Rta59hsbireGzhh3A= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.37 h1:a3D4AjrOrTrP8+d9ILBthqrElf0z1JNol09Xvnwcys8= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.37/go.mod h1:ky0gTu+ukvUTuUKFIpp6Wid4oninrkCyvbFkVs0kpHM= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.38 h1:gX8B8y3Ho30B1LPxefDKMi/HZqWEb47U9ogs3DtSG0M= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.38/go.mod h1:l5WblZlcmGPe4/O7JY2HO25Z+xqTBvyfTyFbRMf8gYw= +github.com/aws/aws-sdk-go-v2/service/s3 v1.107.2 h1:GNU0/xtPEXMKilJZ/a8BedeuQnvu+Usi6qVm9EFfncc= +github.com/aws/aws-sdk-go-v2/service/s3 v1.107.2/go.mod h1:4jYWUecEsQtE73jPl7p3jrbYXH5ffcR4gegyCygagfg= +github.com/aws/aws-sdk-go-v2/service/signin v1.5.6 h1:i68sFvXidKlkiSvI7d7Ilc1/UvW4CtBOaivH7jhG4fs= +github.com/aws/aws-sdk-go-v2/service/signin v1.5.6/go.mod h1:/h7Obr9WTtzbjTHGASRQwLN7Bupw+TC3x8x7fyx39hE= +github.com/aws/aws-sdk-go-v2/service/sso v1.33.6 h1:tpfGChmjUmv3W9WlRvy+stwKDTbFFdq8Zk9DbFPrfMU= +github.com/aws/aws-sdk-go-v2/service/sso v1.33.6/go.mod h1:CSjiDzmG/lsKkTOYjbkM+duLmRlW+LOxD64Na44ijnI= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.6 h1:49BBtY68A+KJCQ3a2F3eUe6ROsKucxUdfHKoqorc0wI= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.6/go.mod h1:ptG2hbs7QltE1GcQY0MpS4bfrc51KCnBXUr7OT1EEfE= +github.com/aws/aws-sdk-go-v2/service/sts v1.45.6 h1:JvExZWabChDM0qJAirQYGfOYo0ndT3edXj+fqSPNjkE= +github.com/aws/aws-sdk-go-v2/service/sts v1.45.6/go.mod h1:XZcaQkV2cItp6yEkrwljyaPOf22RuX7T43jxap/FOmM= +github.com/aws/smithy-go v1.27.8 h1:FR0dxZfIlV7Z8eh2iHfIofdunw382XsDV3Mxt9nUvRY= +github.com/aws/smithy-go v1.27.8/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= diff --git a/go.mod b/go.mod index 0df4adf5b0..411fe01715 100644 --- a/go.mod +++ b/go.mod @@ -21,37 +21,37 @@ require ( github.com/joho/godotenv v1.5.1 github.com/lib/pq v1.12.3 github.com/pkg/errors v0.9.1 - github.com/prometheus/client_golang v1.24.0 + github.com/prometheus/client_golang v1.24.1 github.com/prometheus/otlptranslator v1.0.0 github.com/redis/go-redis/extra/redisotel/v9 v9.21.0 github.com/redis/go-redis/v9 v9.21.0 github.com/segmentio/kafka-go v0.4.51 github.com/stretchr/testify v1.11.1 github.com/vektah/gqlparser/v2 v2.5.36 - go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.69.0 - go.opentelemetry.io/otel v1.44.0 + go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.70.0 + go.opentelemetry.io/otel v1.45.0 go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.44.0 go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.44.0 go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0 - go.opentelemetry.io/otel/exporters/prometheus v0.65.0 + go.opentelemetry.io/otel/exporters/prometheus v0.67.0 go.opentelemetry.io/otel/exporters/zipkin v1.44.0 - go.opentelemetry.io/otel/metric v1.44.0 - go.opentelemetry.io/otel/sdk v1.44.0 - go.opentelemetry.io/otel/sdk/metric v1.44.0 - go.opentelemetry.io/otel/trace v1.44.0 + go.opentelemetry.io/otel/metric v1.45.0 + go.opentelemetry.io/otel/sdk v1.45.0 + go.opentelemetry.io/otel/sdk/metric v1.45.0 + go.opentelemetry.io/otel/trace v1.45.0 go.uber.org/goleak v1.3.0 go.uber.org/mock v0.6.0 golang.org/x/oauth2 v0.36.0 golang.org/x/sync v0.22.0 golang.org/x/term v0.45.0 - golang.org/x/text v0.40.0 + golang.org/x/text v0.41.0 golang.org/x/time v0.15.0 google.golang.org/api v0.291.0 google.golang.org/genproto/googleapis/rpc v0.0.0-20260724162435-b2f20204f0df google.golang.org/grpc v1.83.0 google.golang.org/protobuf v1.36.11 gopkg.in/yaml.v3 v3.0.1 - modernc.org/sqlite v1.55.0 + modernc.org/sqlite v1.56.0 ) require ( @@ -59,7 +59,7 @@ require ( cloud.google.com/go/auth v0.22.0 // indirect cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect cloud.google.com/go/compute/metadata v0.9.0 // indirect - cloud.google.com/go/iam v1.11.0 // indirect + cloud.google.com/go/iam v1.12.0 // indirect cloud.google.com/go/pubsub/v2 v2.6.0 // indirect filippo.io/edwards25519 v1.2.0 // indirect github.com/agnivade/levenshtein v1.2.1 // indirect @@ -70,7 +70,7 @@ require ( github.com/dustin/go-humanize v1.0.1 // indirect github.com/felixge/httpsnoop v1.1.0 // indirect github.com/fsnotify/fsnotify v1.6.0 // indirect - github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/logr v1.4.4 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect github.com/google/go-cmp v0.7.0 // indirect @@ -78,15 +78,15 @@ require ( github.com/googleapis/enterprise-certificate-proxy v0.3.19 // indirect github.com/googleapis/gax-go/v2 v2.23.0 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect - github.com/klauspost/compress v1.19.0 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect + github.com/klauspost/compress v1.19.2 // indirect + github.com/mattn/go-isatty v0.0.24 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect github.com/openzipkin/zipkin-go v0.4.3 // indirect github.com/pierrec/lz4/v4 v4.1.27 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_model v0.6.2 // indirect - github.com/prometheus/common v0.70.0 // indirect + github.com/prometheus/common v0.70.1 // indirect github.com/prometheus/procfs v0.21.1 // indirect github.com/redis/go-redis/extra/rediscmd/v9 v9.21.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect @@ -99,16 +99,16 @@ require ( go.opencensus.io v0.24.0 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.68.0 // indirect - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 // indirect go.opentelemetry.io/proto/otlp v1.10.0 // indirect go.uber.org/atomic v1.11.0 // indirect - golang.org/x/crypto v0.54.0 // indirect + golang.org/x/crypto v0.55.0 // indirect golang.org/x/net v0.57.0 // indirect golang.org/x/sys v0.47.0 // indirect - google.golang.org/genproto v0.0.0-20260519071638-aa98bba5eb94 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20260630182238-925bb5da69e7 // indirect - modernc.org/libc v1.74.1 // indirect + google.golang.org/genproto v0.0.0-20260723164925-7274b71286bd // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260723164925-7274b71286bd // indirect + modernc.org/libc v1.74.4 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect ) diff --git a/go.sum b/go.sum index 1ec7c592c8..91029e3b6f 100644 --- a/go.sum +++ b/go.sum @@ -7,10 +7,10 @@ cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIi cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= -cloud.google.com/go/iam v1.11.0 h1:KieQ9Pb+LLPak1O3Rv3GgCxhnmkYf7Xyh0P5HfF1jFM= -cloud.google.com/go/iam v1.11.0/go.mod h1:KP+nKGugNJW4LcLx1uEZcq1ok5sQHFaQehQNl4QDgV4= -cloud.google.com/go/kms v1.31.0 h1:LS8N92OxFDgOLg5NCo3OmbvjtQAIVT5gUHVLKIDHaFE= -cloud.google.com/go/kms v1.31.0/go.mod h1:YIyXZym11R5uovJJt4oN5eUL3oPmirF3yKeIh6QAf4U= +cloud.google.com/go/iam v1.12.0 h1:Aki3bX9aHUDKPHfnRJfDcTdVedvy6quGBQcTqx3DRXk= +cloud.google.com/go/iam v1.12.0/go.mod h1:FEZ4lXpADAC2AIpQY7LANNjjwyQ2jK439CI2VaD+sLY= +cloud.google.com/go/kms v1.32.0 h1:s+rEluaaZKhLVjrIWG7uNBsnWbiitElzNzFGyp6+nIg= +cloud.google.com/go/kms v1.32.0/go.mod h1:CSGvW6GnMQbY+1nOHcIzhMtHSbExXlOmCKjWtYVjcpA= cloud.google.com/go/longrunning v1.2.0 h1:WjYH3YHBGCxGJP9M4dWGHBfXr/cFIjMkNgWcJj7/iMM= cloud.google.com/go/longrunning v1.2.0/go.mod h1:5KMQALFGOCtFoi2xSOA1u3H7WKlhmckgiyFw7+LGQp0= cloud.google.com/go/pubsub v1.51.0 h1:XOaCejsqX7EEtUdQz+WPag66wWsUUGliyCOfGPKfo90= @@ -71,8 +71,8 @@ github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9 github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY= github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= -github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= -github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= +github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-redis/redismock/v9 v9.2.0 h1:ZrMYQeKPECZPjOj5u9eyOjg8Nnb0BS9lkVIZ6IpsKLw= @@ -107,8 +107,8 @@ github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= -github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo= +github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk= github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= @@ -135,8 +135,8 @@ github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwA github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/kisielk/sqlstruct v0.0.0-20201105191214-5f3e10d3ab46/go.mod h1:yyMNCyc/Ib3bDTKd379tNMpB/7/H5TjM2Y9QJ5THLbE= -github.com/klauspost/compress v1.19.0 h1:sXLILfc9jV2QYWkzFOPWStmcUVH2RHEB1JCdY2oVvCQ= -github.com/klauspost/compress v1.19.0/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/klauspost/compress v1.19.2 h1:hMRETovs/pu/dVWN7zIT1PGG8t509MwT6bO7XSi26R8= +github.com/klauspost/compress v1.19.2/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= @@ -147,8 +147,8 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0 github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ= github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= +github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= @@ -170,13 +170,13 @@ github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/prometheus/client_golang v1.24.0 h1:5XStIklKuAtJSNpdD3s8XJj/Yv78IQmE1kbNk87JrAI= -github.com/prometheus/client_golang v1.24.0/go.mod h1:QcsNdotprC2nS4BTM2ucbcqxd2CeXTEa9jW7zHO9iDE= +github.com/prometheus/client_golang v1.24.1 h1:JnJkREXzWxUdCuPFpIWZiPispT9xVV59uiuyR2bPlnU= +github.com/prometheus/client_golang v1.24.1/go.mod h1:F+oSRECHg4sse5ucfYpYDeIv/hu68Zo0uoHKetWnzcE= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= -github.com/prometheus/common v0.70.0 h1:bcpru3tWPVnxGnETLgOV5jbp/JRXgYEyv65CuBLAMMI= -github.com/prometheus/common v0.70.0/go.mod h1:S/SFasQmgGiYH6C81LKCtYa8QACgthGg5zxL2udV7SY= +github.com/prometheus/common v0.70.1 h1:1HvjP4D5oL3t8RsPlwxA9onvvStjtIHYE5XuuwOi/PY= +github.com/prometheus/common v0.70.1/go.mod h1:VdFUQDMZK3VLkurFUVhia6uys/0suUp86TJz5qbJRhc= github.com/prometheus/otlptranslator v1.0.0 h1:s0LJW/iN9dkIH+EnhiD3BlkkP5QVIUVEoIwkU+A6qos= github.com/prometheus/otlptranslator v1.0.0/go.mod h1:vRYWnXvI6aWGpsdY/mOT/cbeVRBlPWtBNDb7kGR3uKM= github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI= @@ -226,12 +226,12 @@ go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.68.0 h1:0Qx7VGBacMm9ZENQ7TnNObTYI4ShC+lHI16seduaxZo= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.68.0/go.mod h1:Sje3i3MjSPKTSPvVWCaL8ugBzJwik3u4smCjUeuupqg= -go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.69.0 h1:MCcYL7J6Vt/X0kjqbMZkekCmwsurbQRbL69vkiye2lk= -go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.69.0/go.mod h1:3jnStNwSufK+f5ktjL4EPcwtig4rtd81NS70lqHuXl8= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 h1:8tvICD4vSTOOsNrsI4Ljf6C+6UKvpTEH5XY3JMoyPoo= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0/go.mod h1:z9+yiacE0IHRqM4qFfkbt/JYlmYXgss8GY/jXoNuPJI= -go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= -go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= +go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.70.0 h1:aVgLpGksz0vjoe6OynycqX8daNOAxJx5ZEhJXIXOVIU= +go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.70.0/go.mod h1:kmJlX6WuTrAH1fOCSbPJFrSnUagB8c3SY3E87It3JD8= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0 h1:LMuyCAyfalSjDyjdC65nK6N0zoTT63+E/u95X0JovZI= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0/go.mod h1:085m8qbm4hgc8rZWGDEa4vmyyo2c3nPxUslYUKUIU04= +go.opentelemetry.io/otel v1.45.0 h1:pdrWmLHofpubmArBv1LgFSv1Z0Ie/ppdZzu+kUN5EeU= +go.opentelemetry.io/otel v1.45.0/go.mod h1:XZxIqPapzEYnhNSScF5DIqXhm/rYi0FzCe2XddAwZfQ= go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.44.0 h1:SUplec5dp06reu1zaXmOXdvqH398taqrDXqUl99jxSc= go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.44.0/go.mod h1:ho2g4N+ane+swq5I/VBkKWnRDY4kUINH3FuqyZqX/Ug= go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.44.0 h1:RuynHbfU8JUEw7DyONgkVYg2SVtsoF28y0LGIr69jgA= @@ -240,20 +240,20 @@ go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 h1:4YsVu3B8+3qtWYYrsUY go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0/go.mod h1:+wnlSn0mD1ADVMe3v9Z/WIaiz6q6gL2J/ejaAmdmv80= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0 h1:qazEJlUOQzhCpzQpFETGby7EdqjI1wsd0W+6Gg1SCTU= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0/go.mod h1:fOD2Yefuxixkx3ahVNf0O/PERb6r4OlbxfATVnYvzCo= -go.opentelemetry.io/otel/exporters/prometheus v0.65.0 h1:jOveH/b4lU9HT7y+Gfamf18BqlOuz2PWEvs8yM7Q6XE= -go.opentelemetry.io/otel/exporters/prometheus v0.65.0/go.mod h1:i1P8pcumauPtUI4YNopea1dhzEMuEqWP1xoUZDylLHo= +go.opentelemetry.io/otel/exporters/prometheus v0.67.0 h1:7IefDa35e6V3NoiqIeLDMDxMFyZDk5qcoC0Ax4cC16E= +go.opentelemetry.io/otel/exporters/prometheus v0.67.0/go.mod h1:nsPI1awTg5Vmg1YrommL2mVarVGlqc4yXOoKAkPRD0c= go.opentelemetry.io/otel/exporters/zipkin v1.44.0 h1:zv7PRYGLrQHkdeZj0c5SNAZOJcw55XgaTezUkNpwA+w= go.opentelemetry.io/otel/exporters/zipkin v1.44.0/go.mod h1:3+VZyCi6hFW+UuxFF+wSOvwsOwncfBpQfP7Qdb3JXKg= -go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= -go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= -go.opentelemetry.io/otel/metric/x v0.66.0 h1:YkCrx1zLOChi9ZcZ6euupOcsgzbVlec7D/xoEU1+cTA= -go.opentelemetry.io/otel/metric/x v0.66.0/go.mod h1:d1+BDj9t96do0/1LoU1ayfCv79ZgNE41qbhBvnMOBZk= -go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= -go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= -go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= -go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= -go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= -go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= +go.opentelemetry.io/otel/metric v1.45.0 h1:7Eg1uH7CJ5cXv9is6tnBe1FI6rj1nwUdbFypRm3br/M= +go.opentelemetry.io/otel/metric v1.45.0/go.mod h1:HAPbm1nd3p1PmFH7v2dR+6BjXxw+Lq4a2+pndMAm08s= +go.opentelemetry.io/otel/metric/x v0.67.0 h1:PcicCNZFkZ4bXfSooXdo3WN7RBOVOtjVdo1wD358Uns= +go.opentelemetry.io/otel/metric/x v0.67.0/go.mod h1:FBjCWZe6wgcqxcMtjdGiClDKXb2YxxXii0CXftE4QtI= +go.opentelemetry.io/otel/sdk v1.45.0 h1:4VVSMgQ83dUgW2aoX5f6JgLvHwIvzcuLnF9lUdCSpCw= +go.opentelemetry.io/otel/sdk v1.45.0/go.mod h1:Sr40LgXV7DsKMMJMKOhUWOgMWTfAaqvm2kF0g7ilwuA= +go.opentelemetry.io/otel/sdk/metric v1.45.0 h1:oVFszMfyj1Am6s24Vtc7wBb8BKLcwepJjNEYILuiE3o= +go.opentelemetry.io/otel/sdk/metric v1.45.0/go.mod h1:vUWUxDZvu1WVRj8JA8S0AdhsPrZoDpA2DdZauIh4mDA= +go.opentelemetry.io/otel/trace v1.45.0 h1:l/mP6Uv7oNO7/TblbhpbgMidxhq1uO/rPsikOyVhxag= +go.opentelemetry.io/otel/trace v1.45.0/go.mod h1:qoJJA2xNMnxRrdISU/kLtfUH2wNeQbiv+jhs/CxI8bc= go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g= go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk= go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= @@ -270,8 +270,8 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= -golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= +golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= +golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= @@ -279,8 +279,8 @@ golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHl golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= -golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= -golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= +golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= +golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -314,7 +314,6 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= @@ -325,8 +324,8 @@ golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= -golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= -golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= +golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= +golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -338,8 +337,8 @@ golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtn golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= -golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= +golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= +golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -353,10 +352,10 @@ google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7 google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= -google.golang.org/genproto v0.0.0-20260519071638-aa98bba5eb94 h1:YJjbgu+dkp5kUJLfpMyCLfBIWZb/FcJyuLeo1gVBOuo= -google.golang.org/genproto v0.0.0-20260519071638-aa98bba5eb94/go.mod h1:RRHjglSYABVCWpQ7USCpdfhcd9t4PkajvVwyynZizTc= -google.golang.org/genproto/googleapis/api v0.0.0-20260630182238-925bb5da69e7 h1:jQ9p21COKWjP3VwuFrNRiiOTMh3mPpN45R7SLrH/HUU= -google.golang.org/genproto/googleapis/api v0.0.0-20260630182238-925bb5da69e7/go.mod h1:KqHwBx2upmfa1XSi1WuRvC+2VGCLtooKkfmyvRbUmqA= +google.golang.org/genproto v0.0.0-20260723164925-7274b71286bd h1:0GnSESHqea5EoaQgJyYxC3o5m0L191gCQdDgo2cxO5I= +google.golang.org/genproto v0.0.0-20260723164925-7274b71286bd/go.mod h1:Wz2wFJntZFmLGo7pLDXZ3wYk5hyc0Mb+SkHhDDXT+lU= +google.golang.org/genproto/googleapis/api v0.0.0-20260723164925-7274b71286bd h1:k+Z6yS8OmX4IJpSXEjeT0nqv6efIFFaa5DfDVeqy16A= +google.golang.org/genproto/googleapis/api v0.0.0-20260723164925-7274b71286bd/go.mod h1:1brfde68Npq6+WA75c1EHWPijZEG1kMus61ygPZfn4A= google.golang.org/genproto/googleapis/rpc v0.0.0-20260724162435-b2f20204f0df h1:O3ig1i5WDDzsVzRp+cCdgelT9vXnlnOFdlEeFtL4HCc= google.golang.org/genproto/googleapis/rpc v0.0.0-20260724162435-b2f20204f0df/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= @@ -389,8 +388,8 @@ gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -modernc.org/cc/v4 v4.29.0 h1:CXgwL8cvxmyzBQZzbSl/6xFtMCryb6u8IOqDci39cgc= -modernc.org/cc/v4 v4.29.0/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= +modernc.org/cc/v4 v4.29.1 h1:MKgdCV3WykTSPqpVrnxdEDS0HEd2FHpKZDzxzU5LyeI= +modernc.org/cc/v4 v4.29.1/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU= modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk= modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= @@ -401,8 +400,8 @@ modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI= modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= -modernc.org/libc v1.74.1 h1:bdR4VTKFMC4966QSNZ05XLGI/VwzVa2kTUX51Dm0riQ= -modernc.org/libc v1.74.1/go.mod h1:uH4t5bOx3G3g9Xcmj10YKlTcVISlRDwv8VoQJG9n8Os= +modernc.org/libc v1.74.4 h1:fX1Omw4o2/1C2iRkkIsrQTasJQldLhRmuPreXLoWs9k= +modernc.org/libc v1.74.4/go.mod h1:eeQAS9W3sZeKYMFubydxJpII9ybHWshk+7or7bLG9co= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= @@ -411,8 +410,8 @@ modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= -modernc.org/sqlite v1.55.0 h1:hIFh0MCH0rGinQ/4KYb5/UbCkRkb+UP+OkLCVWa5MTM= -modernc.org/sqlite v1.55.0/go.mod h1:4ntCLuNmnH8+GNqjka1wNg7KJd5/Hi5FYp8K+XQ7GZw= +modernc.org/sqlite v1.56.0 h1:/D8e2RfFqoy/Zc6PuC76U28zFwmI/sYx1Kjm4yEn9e0= +modernc.org/sqlite v1.56.0/go.mod h1:yCJ2cmAaIkHQ25oXWrF8H4O1lIfPYPR26yCEDj2P3pQ= modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= diff --git a/go.work.sum b/go.work.sum index 1432d885d0..09eeca8bc4 100644 --- a/go.work.sum +++ b/go.work.sum @@ -40,6 +40,8 @@ cloud.google.com/go/accesscontextmanager v1.9.7 h1:aKIfg7Jyc73pe8bzx0zypNdS5gfFd cloud.google.com/go/accesscontextmanager v1.9.7/go.mod h1:i6e0nd5CPcrh7+YwGq4bKvju5YB9sgoAip+mXU73aMM= cloud.google.com/go/accesscontextmanager v1.14.0 h1:50ofyZiGo2yL3Wt1gZ0j0QnD9y3YrhhcwX0N08uS6KY= cloud.google.com/go/accesscontextmanager v1.14.0/go.mod h1:VO15iVnsM0FO9Dt8hSFPgkuHRZjq6LEYZq1szJ27U2k= +cloud.google.com/go/accesscontextmanager v1.15.0 h1:0baVgug9IFV8y5cIoD4iN/m/QtJzk2k+j3MW+zJWCVw= +cloud.google.com/go/accesscontextmanager v1.15.0/go.mod h1:YjW9urferk8i9ALwBF3bmdcogZeQYRn2yWwR8nkhsBc= cloud.google.com/go/aiplatform v1.89.0 h1:niSJYc6ldWWVM9faXPo1Et1MVSQoLvVGriD7fwbJdtE= cloud.google.com/go/aiplatform v1.89.0/go.mod h1:TzZtegPkinfXTtXVvZZpxx7noINFMVDrLkE7cEWhYEk= cloud.google.com/go/aiplatform v1.102.0 h1:UWw1hrxIFoXeooNdJSjTJyHAcIf67OwyVoqcpdScVoA= @@ -54,6 +56,8 @@ cloud.google.com/go/aiplatform v1.120.0 h1:jKWTpEs+xoUhDa1FMdSuhMcEQYyUiMdufGyX3 cloud.google.com/go/aiplatform v1.120.0/go.mod h1:6mDthfmy0oS1EQhVFdijoxkVdI2+HIZkpuGTBpedeCg= cloud.google.com/go/aiplatform v1.125.0 h1:QUGv+XaHN9wcWdb0/J0NFIcaP/veQSvDcqg4GH6QiP4= cloud.google.com/go/aiplatform v1.125.0/go.mod h1:yWTZiCunYDnyxeWWD14tDo6+BMlvAUCC5VxuxhvbrVI= +cloud.google.com/go/aiplatform v1.126.0 h1:5PxeWpQfkAyN8mtgtVp4H5nP+ntwPLFMjNREVdiolR0= +cloud.google.com/go/aiplatform v1.126.0/go.mod h1:iR3za3evdprLe1XL2pLu0cYVCuTbc87QG0pgvcgiJlE= cloud.google.com/go/analytics v0.28.1 h1:W2ft49J/LeEj9A07Jsd5Q2kAzajK0j0IffOyyzbxw04= cloud.google.com/go/analytics v0.28.1/go.mod h1:iPaIVr5iXPB3JzkKPW1JddswksACRFl3NSHgVHsuYC4= cloud.google.com/go/analytics v0.30.0 h1:9PvoT9SvNIHRqTZye7+WudvO2vr4PiZ9mLhiOtEE7Eo= @@ -68,6 +72,8 @@ cloud.google.com/go/apigateway v1.7.7 h1:ehKUTy+QFsb3n07fEi18S2dpDDjCV4UlRyrbwfZ cloud.google.com/go/apigateway v1.7.7/go.mod h1:j1bCmrUK1BzVHpiIyTApxB7cRyhivKzltqLmp6j6i7U= cloud.google.com/go/apigateway v1.12.0 h1:fpSMzMpRFOS3OAQBdiX3LIKNEGYe0ley2EI+lGaCK2s= cloud.google.com/go/apigateway v1.12.0/go.mod h1:f3Sk8Tdh1Ty5HR7kgbWB6Yu1M82LM+nIr5DTMZnLZWk= +cloud.google.com/go/apigateway v1.13.0 h1:IENNmFQlMsl58RlvYHf6wxBNQFeP+lAnVNcyxtmByTE= +cloud.google.com/go/apigateway v1.13.0/go.mod h1:pvEpOuuOIw2ev9VCcOyVkDXHHL4lvgMuqIe7XjJ8JoU= cloud.google.com/go/apigeeconnect v1.7.6 h1:ijEJSni5xROOn1YyiHgqcW0B0TWr0di9VgIi2gvyNjY= cloud.google.com/go/apigeeconnect v1.7.6/go.mod h1:zqDhHY99YSn2li6OeEjFpAlhXYnXKl6DFb/fGu0ye2w= cloud.google.com/go/apigeeconnect v1.7.7 h1:S6s2zojwMymx0fyZYKm0eK1TdDxrriIBAlNVvRAOzug= @@ -80,12 +86,16 @@ cloud.google.com/go/apigeeregistry v0.10.0 h1:QziFVsuPU2lhy40Ht9uWEyciV23SH9GETW cloud.google.com/go/apigeeregistry v0.10.0/go.mod h1:SAlF5OhKvyLDuwWAaFAIVJjrEqKRrGTPkJs+TWNnSqg= cloud.google.com/go/apigeeregistry v1.0.0 h1:S0DHrbgpO8/b+YJY/Af2rEQ5d7dkiO1LB59UdvkS6Aw= cloud.google.com/go/apigeeregistry v1.0.0/go.mod h1:o+j6eA8hYhTWX5gEqMMBVDWY+/QQFrYe/YJBsO19pn0= +cloud.google.com/go/apigeeregistry v1.1.0 h1:2zIogaYq7dF4E1dAGC4MUnasRuSmohsMl7wVuJ963/4= +cloud.google.com/go/apigeeregistry v1.1.0/go.mod h1:4ZFhQlxMuyfDMz9ORDSV8FPZtf2yPQkKjigsFtrrE4Y= cloud.google.com/go/appengine v1.9.6 h1:JJyY8icMmQeWfQ+d36IhkGvd3Guzvw0UAkvxT0wmUx8= cloud.google.com/go/appengine v1.9.6/go.mod h1:jPp9T7Opvzl97qytaRGPwoH7pFI3GAcLDaui1K8PNjY= cloud.google.com/go/appengine v1.9.7 h1:IxGz6j5xv0nTJX285wu95Vn6KEi2CeV9vbyRgCSEAoU= cloud.google.com/go/appengine v1.9.7/go.mod h1:y1XpGVeAhbsNzHida79cHbr3pFRsym0ob8xnC8yphbo= cloud.google.com/go/appengine v1.14.0 h1:dTww1xDqBpeR0BpLsiqfjyAnaK7S1vniJ5YR7L83Jh4= cloud.google.com/go/appengine v1.14.0/go.mod h1:JMjrVFg+YgfksZCWbtA3TgbKbPfZZtapB9cGL/5WVnM= +cloud.google.com/go/appengine v1.15.0 h1:0MiBM2KGk1WAhh489WoELQ7IeW2qw6HTE6nZIlrOw5U= +cloud.google.com/go/appengine v1.15.0/go.mod h1:/8gGZsOX5GDjOo4mAWk8IV59p2991dxTbEtKIlhDjzU= cloud.google.com/go/area120 v0.9.6 h1:iJrZ6AleZr4l+q0/fWVANFOhs90KiSB1Ccait5OYyNg= cloud.google.com/go/area120 v0.9.6/go.mod h1:qKSokqe0iTmwBDA3tbLWonMEnh0pMAH4YxiceiHUed4= cloud.google.com/go/area120 v0.9.7 h1:BbpzLwaIXVPorrrzTH+ni7P5mLemmPPfSZ7o39k7zQc= @@ -104,6 +114,8 @@ cloud.google.com/go/artifactregistry v1.20.0 h1:j/XQiQfaeTyQeNj3HNk4iDFREVnY/fxk cloud.google.com/go/artifactregistry v1.20.0/go.mod h1:0G9wdbGyDFkvrYH+2AlQs9MuTJdbY8Vg45M8VjlI8rc= cloud.google.com/go/artifactregistry v1.25.0 h1:CWAoXkJBX02h68W7Z2ZNBqvH1wxET3s+fnKZcn2gM3c= cloud.google.com/go/artifactregistry v1.25.0/go.mod h1:aMmdtqKVmbuxCCb/NGDJYZHsK6AtqlcyvD05ACzs1n8= +cloud.google.com/go/artifactregistry v1.26.0 h1:iq5kkdY2FJY8RkkNghUelb3EOcqyy7dVhfDGVk/Xw9g= +cloud.google.com/go/artifactregistry v1.26.0/go.mod h1:c5FPi5GtDBP+OAr5kKhCBNQDT9ZgAyobXQjekx93VWs= cloud.google.com/go/asset v1.21.1 h1:i55wWC/EwVdHMyJgRfbLp/L6ez4nQuOpZwSxkuqN9ek= cloud.google.com/go/asset v1.21.1/go.mod h1:7AzY1GCC+s1O73yzLM1IpHFLHz3ws2OigmCpOQHwebk= cloud.google.com/go/asset v1.22.0 h1:81Ru5hjHfiGtk+u/Ix69eaWieKpvm7Ce7UHtcZhOLbk= @@ -112,12 +124,16 @@ cloud.google.com/go/asset v1.22.1 h1:wimPPWu5gjBkPY1576vr+YxfoLKVhAK9zM2XrEpdKQ4 cloud.google.com/go/asset v1.22.1/go.mod h1:NlvWwmca7CX6BIBEdRNxOocH6DowmBghAAHucOHuHng= cloud.google.com/go/asset v1.27.0 h1:Lj2lg/FB7VIBAkvUTVVx7Z9HRSPyVw7WN9butFJSONg= cloud.google.com/go/asset v1.27.0/go.mod h1:+HaDReZQAh/0syAf0uTMeUrMfXikr+KKyDtCdvf7j4M= +cloud.google.com/go/asset v1.28.0 h1:M6YE1exBuZQhTi8wfIKrwjKK6b2ySfHKgrqXjPZA4EM= +cloud.google.com/go/asset v1.28.0/go.mod h1:Pnvjhay8/FgodOH9uJC8OkfJfRtSnNIIU4WSxg5JfJw= cloud.google.com/go/assuredworkloads v1.12.6 h1:ip/shfJYx6lrHBWYADjrrrubcm7uZzy50TTF5tPG7ek= cloud.google.com/go/assuredworkloads v1.12.6/go.mod h1:QyZHd7nH08fmZ+G4ElihV1zoZ7H0FQCpgS0YWtwjCKo= cloud.google.com/go/assuredworkloads v1.13.0 h1:NQXyyGLksPmiapE1Oc64a3cMwYIBAoDBg6cWR+B3eaY= cloud.google.com/go/assuredworkloads v1.13.0/go.mod h1:o/oHEOnUlribR+uJWTKQo8A5RhSl9K9FNeMOew4TJ3M= cloud.google.com/go/assuredworkloads v1.18.0 h1:jk+W89a1UsdIzybt/UbRMRlJTXCqQaGuKCoFNz7nUb4= cloud.google.com/go/assuredworkloads v1.18.0/go.mod h1:zBnVYn0E+sDW/mhEmcg1R8+8tguXrtBgmfGY0q34kss= +cloud.google.com/go/assuredworkloads v1.19.0 h1:XyNQ9SBX0eUkZYCZhC8dOY6ZFeSF6/OIjHn5dQs78Jo= +cloud.google.com/go/assuredworkloads v1.19.0/go.mod h1:/UGGtFCMokM3sGJ4FxjfmLvvFpPa5I/Oz68mwk4Su+0= cloud.google.com/go/auth v0.16.0/go.mod h1:1howDHJ5IETh/LwYs3ZxvlkXF48aSqqJUM+5o02dNOI= cloud.google.com/go/auth v0.16.1/go.mod h1:1howDHJ5IETh/LwYs3ZxvlkXF48aSqqJUM+5o02dNOI= cloud.google.com/go/auth v0.16.3/go.mod h1:NucRGjaXfzP1ltpcQ7On/VTZ0H4kWB5Jy+Y9Dnm76fA= @@ -132,12 +148,16 @@ cloud.google.com/go/automl v1.15.0 h1:YRwLbsBv4yApX64pkrdyy4emhWE6lHEnljX4b1aTQC cloud.google.com/go/automl v1.15.0/go.mod h1:U9zOtQb8zVrFNGTuW3BfxeqmLyeleLgT9B12EaXfODg= cloud.google.com/go/automl v1.20.0 h1:Gh9BlFogtzwSxaEfnx33XD8xJ+z0v33yLQd46/Ka7uM= cloud.google.com/go/automl v1.20.0/go.mod h1:OkHxjbVDblDafhwuP8yEkz1xcUJhgcbhbsieCW7GaiI= +cloud.google.com/go/automl v1.21.0 h1:brKXr4i9AMVfgbCKOYSu4rnIoz1ILIgcIAy3nerzEGc= +cloud.google.com/go/automl v1.21.0/go.mod h1:MNbhUevuECzM3jqSOM7hmOedOdRJkm8xbbXW44SU15U= cloud.google.com/go/baremetalsolution v1.3.6 h1:9bdGlpY1LgLONQjFsDwrkjLzdPTlROpfU+GhA97YpOk= cloud.google.com/go/baremetalsolution v1.3.6/go.mod h1:7/CS0LzpLccRGO0HL3q2Rofxas2JwjREKut414sE9iM= cloud.google.com/go/baremetalsolution v1.4.0 h1:g67fjVdrNCHZl8jDWdZvo+6zGTTMMuvNWO7HSgG8lnI= cloud.google.com/go/baremetalsolution v1.4.0/go.mod h1:K6C6g4aS8LW95I0fEHZiBsBlh0UxwDLGf+S/vyfXbvg= cloud.google.com/go/baremetalsolution v1.9.0 h1:c56Ygy+4Lr8WtnLG4nV2VIzhhGVgsa6gSEPdp83/PYI= cloud.google.com/go/baremetalsolution v1.9.0/go.mod h1:o+stutiS8t+HmjNIG92Gkn8H9+5/q27d6lQp7e9GWdg= +cloud.google.com/go/baremetalsolution v1.10.0 h1:6MaZilXzGZJ3mGqbSCdDezs9yrze6kcmBABsRUZ/aYI= +cloud.google.com/go/baremetalsolution v1.10.0/go.mod h1:xhhT9VQiKPFd2fUs4oeDSRrxV0sb0PGeVmuZoUE2cBA= cloud.google.com/go/batch v1.12.2 h1:gWQdvdPplptpvrkqF6ibtxZkOsYKLTFbxYawHa/TvCg= cloud.google.com/go/batch v1.12.2/go.mod h1:tbnuTN/Iw59/n1yjAYKV2aZUjvMM2VJqAgvUgft6UEU= cloud.google.com/go/batch v1.13.0 h1:6gmnNhJhm+Y598CZE6x+CeNZNIPCkYa4vkF58S5abHo= @@ -146,12 +166,16 @@ cloud.google.com/go/batch v1.14.0 h1:r5DEMPNXZk1as36Le3DaNQTRhhnR+E95a99SFxwF52o cloud.google.com/go/batch v1.14.0/go.mod h1:oeQveyG6NDS/ks2ilOP4LzKRmuIaI7GLe0CkR7WF6pk= cloud.google.com/go/batch v1.19.0 h1:i4xCFKCvzfkSldUPYWL+DgBpKVTC3N8DSK6E9rFSbqQ= cloud.google.com/go/batch v1.19.0/go.mod h1:dpWfhLmLQZqsTBAFYjZA3pS04fCY5ttTenZcWmSeILw= +cloud.google.com/go/batch v1.20.0 h1:nTL5x9HA1yVdPIdeqcbLjNnV9iYDYvMNmP6AnZ+qbHU= +cloud.google.com/go/batch v1.20.0/go.mod h1:ABT/5QqsIDsONa+n/8C7XYPjwh/kjOEPXukcRTaMsCg= cloud.google.com/go/beyondcorp v1.1.6 h1:4FcR+4QmcNGkhVij6TrYS4AQVNLBo7PBXKxNrKzpclQ= cloud.google.com/go/beyondcorp v1.1.6/go.mod h1:V1PigSWPGh5L/vRRmyutfnjAbkxLI2aWqJDdxKbwvsQ= cloud.google.com/go/beyondcorp v1.2.0 h1:mre997ya7QHFWSU+O5cT/FhBKTMy6Riqf1EXFxN46zw= cloud.google.com/go/beyondcorp v1.2.0/go.mod h1:sszcgxpPPBEfLzbI0aYCTg6tT1tyt3CmKav3NZIUcvI= cloud.google.com/go/beyondcorp v1.7.0 h1:SHAZlC51z6ZO/OZZABnrI/Yk/z3GkhBREQC7qtgTo2I= cloud.google.com/go/beyondcorp v1.7.0/go.mod h1:vujdO0wfsBV2y1egrJxGtwKZr5P5V6bIHKWp1phWHBY= +cloud.google.com/go/beyondcorp v1.8.0 h1:rsyle6zjxce2s57BpULw5u170xS+KSjFENSY7C0MZbs= +cloud.google.com/go/beyondcorp v1.8.0/go.mod h1:aVxzwamO8H4GXWQHowBAmL0KYNfYpW4E6Do2wfP0RYs= cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o= cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE= cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc= @@ -170,6 +194,8 @@ cloud.google.com/go/bigquery v1.74.0 h1:Q6bAMv+eyvufOpIrfrYxhM46qq1D3ZQTdgUDQqKS cloud.google.com/go/bigquery v1.74.0/go.mod h1:iViO7Cx3A/cRKcHNRsHB3yqGAMInFBswrE9Pxazsc90= cloud.google.com/go/bigquery v1.77.0 h1:L5AW3jhzEKpFVg4i0mVHxKpxogrqT7dczWBSr4m9MKU= cloud.google.com/go/bigquery v1.77.0/go.mod h1:J4wuqka/1hEpdJxH2oBrUR0vjTD+r7drGkpcA3yqERM= +cloud.google.com/go/bigquery v1.79.0 h1:+tW6oRuP/4dOgdl/p32on0+3OktVQtU+veOMxhjoOhE= +cloud.google.com/go/bigquery v1.79.0/go.mod h1:QTt5tgZxqqvZs3dOZKpvriGqy+CdvY9LyetirFZRPOE= cloud.google.com/go/bigtable v1.37.0 h1:Q+x7y04lQ0B+WXp03wc1/FLhFt4CwcQdkwWT0M4Jp3w= cloud.google.com/go/bigtable v1.37.0/go.mod h1:HXqddP6hduwzrtiTCqZPpj9ij4hGZb4Zy1WF/dT+yaU= cloud.google.com/go/bigtable v1.39.0 h1:NF0aaSend+Z5CKND2vWY9fgDwaeZ4bDgzUdgw8rk75Y= @@ -194,12 +220,16 @@ cloud.google.com/go/binaryauthorization v1.10.0 h1:YYK0BwiZv9uA6z+Ict908AykX4OBf cloud.google.com/go/binaryauthorization v1.10.0/go.mod h1:WOuiaQkI4PU/okwrcREjSAr2AUtjQgVe+PlrXKOmKKw= cloud.google.com/go/binaryauthorization v1.15.0 h1:yzkO2Hv1HHDs3+98Twtae9a9a2bEkufu7zTc9tRCiMc= cloud.google.com/go/binaryauthorization v1.15.0/go.mod h1:+0CndCJPtcHuVCNok+qQskWvbP5Sp5m6eGL8Vpu5mss= +cloud.google.com/go/binaryauthorization v1.16.0 h1:f1iNUaHWP9XPdh6grftTlIRuJSBytud4j51l1Jce94E= +cloud.google.com/go/binaryauthorization v1.16.0/go.mod h1:E+iC5Avu4pdItdzGiSGHnh6TfQrl+KmPxDDg/T/VuHs= cloud.google.com/go/certificatemanager v1.9.5 h1:+ZPglfDurCcsv4azizDFpBucD1IkRjWjbnU7zceyjfY= cloud.google.com/go/certificatemanager v1.9.5/go.mod h1:kn7gxT/80oVGhjL8rurMUYD36AOimgtzSBPadtAeffs= cloud.google.com/go/certificatemanager v1.9.6 h1:v5X8X+THKrS9OFZb6k0GRDP1WQxLXTdMko7OInBliw4= cloud.google.com/go/certificatemanager v1.9.6/go.mod h1:vWogV874jKZkSRDFCMM3r7wqybv8WXs3XhyNff6o/Zo= cloud.google.com/go/certificatemanager v1.14.0 h1:31fCXgMFDLSXh9HeF2M6hLE+dPF/1UFyIJXLmqpr41g= cloud.google.com/go/certificatemanager v1.14.0/go.mod h1:QOA8qRoM6/Ik03+srLnBykenGTy0fk78dnPcx5ZWOW8= +cloud.google.com/go/certificatemanager v1.15.0 h1:PwljEZlI3lZgoFUG0pnIBNOwrQUaAP+gS0ZahTwUZBA= +cloud.google.com/go/certificatemanager v1.15.0/go.mod h1:8dfGG2/TbUpCNqsCF/TIMOGV0OVvU6nhkZWTU4MmCXU= cloud.google.com/go/channel v1.19.5 h1:UI+ZsRkS15hi9DRF+WAvTVLVuSeZiRmvCU8cjkjOwUU= cloud.google.com/go/channel v1.19.5/go.mod h1:vevu+LK8Oy1Yuf7lcpDbkQQQm5I7oiY5fFTn3uwfQLY= cloud.google.com/go/channel v1.20.0 h1:EeUa6SnD3+EL9B06G6N9Ud5/p/NtT6PC7lv5kmaUiHs= @@ -208,6 +238,8 @@ cloud.google.com/go/channel v1.21.0 h1:ThoAmHBd9WkX2SSuF6n6uEOvbBNoTuhBT7Rk6bFS5 cloud.google.com/go/channel v1.21.0/go.mod h1:8v3TwHtgLmFxTpL2U+e10CLFOQN8u/Vr9RhYcJUS3y8= cloud.google.com/go/channel v1.26.0 h1:lvEuQo7hmVsgedO9aLaIBvXRVg5EoK3jskKdYJl+Vyg= cloud.google.com/go/channel v1.26.0/go.mod h1:04T5Wjq+mHlvEUNzExydnBW1vO64q3Q2Wsblp/dpBxY= +cloud.google.com/go/channel v1.27.0 h1:fqDZDzVw61c4iIUe7rQ+cBYlBdSZ3yqnzw5WrZTPjqs= +cloud.google.com/go/channel v1.27.0/go.mod h1:9ekufBLXuQ6j1oyqtDSIp29qWU5EwCi8WUi9qkLn3MA= cloud.google.com/go/cloudbuild v1.22.2 h1:4LlrIFa3IFLgD1mGEXmUE4cm9fYoU71OLwTvjM7Dg3c= cloud.google.com/go/cloudbuild v1.22.2/go.mod h1:rPyXfINSgMqMZvuTk1DbZcbKYtvbYF/i9IXQ7eeEMIM= cloud.google.com/go/cloudbuild v1.23.0 h1:ycLO1q8CdpDqWKpcqlcP+RMbkUguXqwvO//Q0vC1/jE= @@ -218,12 +250,16 @@ cloud.google.com/go/cloudbuild v1.25.0 h1:Fkg+iJdN7bfICZJzLr/XV+k9aVxXS/hakIlhjD cloud.google.com/go/cloudbuild v1.25.0/go.mod h1:lCu+T6IPkobPo2Nw+vCE7wuaAl9HbXLzdPx/tcF+oWo= cloud.google.com/go/cloudbuild v1.30.0 h1:iOvtaQAcMmdLJaseR6qV76RgFHAAwZlwbpHwWMTqIdo= cloud.google.com/go/cloudbuild v1.30.0/go.mod h1:rg52xEmndQQPiC9NV/8sCaVtKxHMU9D9MeU+oE9VGKA= +cloud.google.com/go/cloudbuild v1.32.0 h1:tlF+KSIJJ0kaKkxACpk9htZ1euDpyOqPuO1aHR4j3oI= +cloud.google.com/go/cloudbuild v1.32.0/go.mod h1:mYgcM8CMaPmAnO7GxSQ9ADAxVRwS+1b7s6WVkt29OXY= cloud.google.com/go/clouddms v1.8.7 h1:IWJbQBEECTaNanDRN1XdR7FU53MJ1nylTl3s9T3MuyI= cloud.google.com/go/clouddms v1.8.7/go.mod h1:DhWLd3nzHP8GoHkA6hOhso0R9Iou+IGggNqlVaq/KZ4= cloud.google.com/go/clouddms v1.8.8 h1:YWsmRXTyK6Ba0hm4qTBak5g1oLhryuM8rSBxHWC8iq4= cloud.google.com/go/clouddms v1.8.8/go.mod h1:QtCyw+a73dlkDb2q20aTAPvfaTZCepDDi6Gb1AKq0a4= cloud.google.com/go/clouddms v1.13.0 h1:/oIzRKf/FgUYqSBwSnwrrtJPkSQ2EMzY8UHQwhGXoJk= cloud.google.com/go/clouddms v1.13.0/go.mod h1:aMgrOZ+/EKF/PL+h1sDbS+7fAIYV5rTwD+G/apCeHQk= +cloud.google.com/go/clouddms v1.14.0 h1:oaktwVyUeKTuzjpde3xbpmc9bAQPQq6WJWOBr7K6YEA= +cloud.google.com/go/clouddms v1.14.0/go.mod h1:qSwET2Q27cJ4wCDsPsbkagXqQqkWfOy+gU3RjMsT/c8= cloud.google.com/go/cloudtasks v1.13.6 h1:Fwan19UiNoFD+3KY0MnNHE5DyixOxNzS1mZ4ChOdpy0= cloud.google.com/go/cloudtasks v1.13.6/go.mod h1:/IDaQqGKMixD+ayM43CfsvWF2k36GeomEuy9gL4gLmU= cloud.google.com/go/cloudtasks v1.13.7 h1:H2v8GEolNtMFfYzUpZBaZbydqU7drpyo99GtAgA+m4I= @@ -238,6 +274,7 @@ cloud.google.com/go/compute v1.49.1 h1:KYKIG0+pfpAWaAYayFkE/KPrAVCge0Hu82bPraAms cloud.google.com/go/compute v1.49.1/go.mod h1:1uoZvP8Avyfhe3Y4he7sMOR16ZiAm2Q+Rc2P5rrJM28= cloud.google.com/go/compute v1.54.0 h1:4CKmnpO+40z44bKG5bdcKxQ7ocNpRtOc9SCLLUzze1w= cloud.google.com/go/compute v1.54.0/go.mod h1:RfBj0L1x/pIM84BrzNX2V21oEv16EKRPBiTcBRRH1Ww= +cloud.google.com/go/compute v1.62.0/go.mod h1:Xm6PbsLgBpAg4va77ljbBdpMjzuU+uPp5Ze2dnZq7lw= cloud.google.com/go/compute v1.63.0 h1:KsBourH0wajM4RhzwPwRMKbxHVdvzGsk7StvACoWXD8= cloud.google.com/go/compute v1.63.0/go.mod h1:Xm6PbsLgBpAg4va77ljbBdpMjzuU+uPp5Ze2dnZq7lw= cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k= @@ -250,6 +287,8 @@ cloud.google.com/go/contactcenterinsights v1.17.4 h1:wA4j99BhsoeYlLx6xEIqrNN1aOT cloud.google.com/go/contactcenterinsights v1.17.4/go.mod h1:kZe6yOnKDfpPz2GphDHynxk/Spx+53UX/pGf+SmWAKM= cloud.google.com/go/contactcenterinsights v1.22.0 h1:VzNZG5RxHhRWlhmPg3GHUmPDqsZXbHq1GJ9yw6ISJbY= cloud.google.com/go/contactcenterinsights v1.22.0/go.mod h1:2Crd36H59Lwkt4gWrLgmnbnF59IIZIa3XYt1gtNqJkQ= +cloud.google.com/go/contactcenterinsights v1.23.0 h1:EtrRf8T7ajR+dR8nseDxwKy3o1VaWP1GFAu7lgMmT9A= +cloud.google.com/go/contactcenterinsights v1.23.0/go.mod h1:uB/kygbfYH/gWEq3NEgq3QRI7/MvpjFyX81ajcW5YAI= cloud.google.com/go/container v1.43.0 h1:A6J92FJPfxTvyX7MHF+w4t2W9WCqvHOi9UB5SAeSy3w= cloud.google.com/go/container v1.43.0/go.mod h1:ETU9WZ1KM9ikEKLzrhRVao7KHtalDQu6aPqM34zDr/U= cloud.google.com/go/container v1.44.0 h1:JEHeW535svvNwJrjrlQ/cdjd15LCWrPKnHsulrufd3A= @@ -260,6 +299,8 @@ cloud.google.com/go/container v1.46.0 h1:xX94Lo3xrS5OkdMWKvpEVAbBwjN9uleVv6vOi02 cloud.google.com/go/container v1.46.0/go.mod h1:A7gMqdQduTk46+zssWDTKbGS2z46UsJNXfKqvMI1ZO4= cloud.google.com/go/container v1.49.0 h1:K4nmtmJezHOzsIyedAOv1Ok36krw1apFmo4zXBaRL1A= cloud.google.com/go/container v1.49.0/go.mod h1:EvqoT2eXfxLweXXUlhAMGR0sOAB00XPzEjoL01esSDs= +cloud.google.com/go/container v1.51.0 h1:KdeWHqwlOvABdhkhSTSb4QFySMOuXnc3UUoQMmq9lic= +cloud.google.com/go/container v1.51.0/go.mod h1:EvqoT2eXfxLweXXUlhAMGR0sOAB00XPzEjoL01esSDs= cloud.google.com/go/containeranalysis v0.14.1 h1:1SoHlNqL3XrhqcoozB+3eoHif2sRUFtp/JeASQTtGKo= cloud.google.com/go/containeranalysis v0.14.1/go.mod h1:28e+tlZgauWGHmEbnI5UfIsjMmrkoR1tFN0K2i71jBI= cloud.google.com/go/containeranalysis v0.14.2 h1:OW2dlMPtR5VnjQGyAP+uJlZahc1l+JFxFlH/J3+l7gw= @@ -272,6 +313,8 @@ cloud.google.com/go/datacatalog v1.26.1 h1:bCRKA8uSQN8wGW3Tw0gwko4E9a64GRmbW1nCb cloud.google.com/go/datacatalog v1.26.1/go.mod h1:2Qcq8vsHNxMDgjgadRFmFG47Y+uuIVsyEGUrlrKEdrg= cloud.google.com/go/datacatalog v1.32.0 h1:fyYn8ODkGil5y3zTIqgIhOfzTu1ACaU2o+C750CO6Ac= cloud.google.com/go/datacatalog v1.32.0/go.mod h1:DE272tynQUwheJeQAyVfV+nO8yrdkuDyOgH2LtOrkWM= +cloud.google.com/go/datacatalog v1.33.0 h1:8V80PpoAGdOOr2QhBrp4wZ66MDCbATdAB/fmVmo5rlU= +cloud.google.com/go/datacatalog v1.33.0/go.mod h1:/EMN04S73fZcPdtNg86VYLDrhi2HheMehQtMCS86Klk= cloud.google.com/go/dataflow v0.11.0 h1:AdhB4cAkMOC9NtrHJxpKOVvO/VqBLaIyk0tEEhbGjYM= cloud.google.com/go/dataflow v0.11.0/go.mod h1:gNHC9fUjlV9miu0hd4oQaXibIuVYTQvZhMdPievKsPk= cloud.google.com/go/dataflow v0.11.1 h1:Z+UYlGrE+IoB+5IAN4/qWdPKO0IpIK9bs2Dy40HK6lg= @@ -286,18 +329,24 @@ cloud.google.com/go/dataform v0.13.0 h1:z4nzTOqGSkJ5ePyJLQiUDTBsPHdokzvNNDhGebGQ cloud.google.com/go/dataform v0.13.0/go.mod h1:U3fqrPY5jAcFh1a8rQb4a+PQ7zKlc5qfgotFZ+luKPo= cloud.google.com/go/dataform v1.0.0 h1:EExrLoU1kh8wYxjeRW/LUIlC4yk4QW5ikoZMbI0mgtE= cloud.google.com/go/dataform v1.0.0/go.mod h1:i1a0zkS751kvrY1IIPpUQZ77H5doxx7cs0AP3hnXTMk= +cloud.google.com/go/dataform v1.2.0 h1:eAIsWhr6AbUIX+wOdfctO6hAr9UoQnTR2qQW739XH1I= +cloud.google.com/go/dataform v1.2.0/go.mod h1:Lhkjd6L04/nBqsEo7S9Tx7D+Vm0pDDDZuKczewAJuX0= cloud.google.com/go/datafusion v1.8.6 h1:GZ6J+CR8CEeWAj8luRCtr8GvImSQRkArIIqGiZOnzBA= cloud.google.com/go/datafusion v1.8.6/go.mod h1:fCyKJF2zUKC+O3hc2F9ja5EUCAbT4zcH692z8HiFZFw= cloud.google.com/go/datafusion v1.8.7 h1:tLCV+xYuOrSjdrRTkc9Cqsb5mBSQEsNfFmuTNYl5/rA= cloud.google.com/go/datafusion v1.8.7/go.mod h1:4dkFb1la41qCEXh1AzYtFwl842bu2ikTUXyKhjvFCb0= cloud.google.com/go/datafusion v1.13.0 h1:rpmpw3F9clEDTk1uCAMjPwJblRGjlW1tQEMEiTC/tR8= cloud.google.com/go/datafusion v1.13.0/go.mod h1:MQdANs3I/4gitzY+mTBx27rrQyMiUg8uc2Z4TPLWWfc= +cloud.google.com/go/datafusion v1.14.0 h1:56rjOW8xFBnTOLgTEWdU09KhVC2/X+y/ukzux7zYqBE= +cloud.google.com/go/datafusion v1.14.0/go.mod h1:2z+uDUKkLPacNNos5lW1Jf1IRDoFyeE+glJ4hmxF2Uc= cloud.google.com/go/datalabeling v0.9.6 h1:VOZ5U+78ttnhNCEID7qdeogqZQzK5N+LPHIQ9Q3YDsc= cloud.google.com/go/datalabeling v0.9.6/go.mod h1:n7o4x0vtPensZOoFwFa4UfZgkSZm8Qs0Pg/T3kQjXSM= cloud.google.com/go/datalabeling v0.9.7 h1:wwoct7mw38s75XvEmLoItQ2TY0RFsGiRDb0iNbXUcX4= cloud.google.com/go/datalabeling v0.9.7/go.mod h1:EEUVn+wNn3jl19P2S13FqE1s9LsKzRsPuuMRq2CMsOk= cloud.google.com/go/datalabeling v0.14.0 h1:hlmO3GBCfiU23UovEKnJoKDKzr+7Du5x1UxXm+4U5AY= cloud.google.com/go/datalabeling v0.14.0/go.mod h1:DYjvP4RhQ0332YgO22APYlBjCebb+SCaS0e2KApDq/Q= +cloud.google.com/go/datalabeling v0.15.0 h1:9TL2kwlO/ODD1fne3uvgTLa53SttwrQi92Bn7KWnKhg= +cloud.google.com/go/datalabeling v0.15.0/go.mod h1:H8WSRKD9XYCDXDlZE3bPgvV7UYI0F05e+ufKev2AFc8= cloud.google.com/go/dataplex v1.25.3 h1:Xr0Toh6wyBlmL3H4EPu1YKwxUtkDSzzq+IP0iLc88kk= cloud.google.com/go/dataplex v1.25.3/go.mod h1:wOJXnOg6bem0tyslu4hZBTncfqcPNDpYGKzed3+bd+E= cloud.google.com/go/dataplex v1.27.1 h1:renSEYTQZMQ3ag7lM0BDmSj4FWqaTGW60YQ/lvAE5iA= @@ -306,6 +355,8 @@ cloud.google.com/go/dataplex v1.28.0 h1:rROI3iqMVI9nXT701ULoFRETQVAOAPC3mPSWFDxX cloud.google.com/go/dataplex v1.28.0/go.mod h1:VB+xlYJiJ5kreonXsa2cHPj0A3CfPh/mgiHG4JFhbUA= cloud.google.com/go/dataplex v1.34.0 h1:WXf+qC/Qhrq6B91HoXYcZJEv1nrLkFpM0HV+JX2SdPs= cloud.google.com/go/dataplex v1.34.0/go.mod h1:sOazL+Bs/PTxiMHQ5yBboBvEW9qPrpGogx3+RAgfIt8= +cloud.google.com/go/dataplex v1.36.0 h1:r4O6RHEkwHr41ItsTUym0ZgH1iDf48R+W8fI7KoMNl8= +cloud.google.com/go/dataplex v1.36.0/go.mod h1:ftgNMXBt+wJ4wPVNvYJ3UY3VTZtKS/i/uFEQppaEbKk= cloud.google.com/go/dataproc/v2 v2.11.2 h1:KhC8wdLILpAs17yeTG6Miwg1v0nOP/OXD+9QNg3w6AQ= cloud.google.com/go/dataproc/v2 v2.11.2/go.mod h1:xwukBjtfiO4vMEa1VdqyFLqJmcv7t3lo+PbLDcTEw+g= cloud.google.com/go/dataproc/v2 v2.14.1 h1:Kxq0iomU0H4MlVP4HYeYPNJnV+YxNctf/hFrprmGy5Y= @@ -316,6 +367,8 @@ cloud.google.com/go/dataproc/v2 v2.16.0 h1:0g2hnjlQ8SQTnNeu+Bqqa61QPssfSZF3t+9ld cloud.google.com/go/dataproc/v2 v2.16.0/go.mod h1:HlzFg8k1SK+bJN3Zsy2z5g6OZS1D4DYiDUgJtF0gJnE= cloud.google.com/go/dataproc/v2 v2.22.0 h1:ypUlQKOHMHGv8FQCCNYd0XyM6tAaMDdbcSFBcjYWhbg= cloud.google.com/go/dataproc/v2 v2.22.0/go.mod h1:oARVSa38kAHvSuG+cozsrY2sE6UajGuvOOf9vS+ADHI= +cloud.google.com/go/dataproc/v2 v2.25.0 h1:IF1wxkXvcgoTP92NVqX6QNDf2SdKNZFPa2IWeSKP310= +cloud.google.com/go/dataproc/v2 v2.25.0/go.mod h1:hkiM6kzc8CwLGoquMN1oghyhuI1fE0girmChH4h9W7w= cloud.google.com/go/dataqna v0.9.7 h1:qTRAG/E3T63Xj1orefRlwupfwH9c9ERUAnWSRGp75so= cloud.google.com/go/dataqna v0.9.7/go.mod h1:4ac3r7zm7Wqm8NAc8sDIDM0v7Dz7d1e/1Ka1yMFanUM= cloud.google.com/go/dataqna v0.9.8 h1:3FREvU+sjaEHSjlKrKF6KjUmafdOvM8CbZ897rttxNs= @@ -332,18 +385,24 @@ cloud.google.com/go/datastore v1.22.0 h1:FOyx2Ag6ibD2wFkz9S8EiNrmBugia8pQOfpyJxi cloud.google.com/go/datastore v1.22.0/go.mod h1:aopSX+Whx0lHspWWBj+AjWt68/zjYsPfDe3LjWtqZg8= cloud.google.com/go/datastore v1.23.0 h1:mAlWN3tnQe1OqVM3UtYBIbWTz9aU83RgW4hXOrfm9P8= cloud.google.com/go/datastore v1.23.0/go.mod h1:bOvQQekv4VACRJmH/MBy12MT6M3udfTuCyxw+tzY+8s= +cloud.google.com/go/datastore v1.25.0 h1:zUjMnCLCcRZVDSdQIXsbnNCl1SVRNw5Jm0J77gPaPKs= +cloud.google.com/go/datastore v1.25.0/go.mod h1:jvJVNe+S2nHVIndV1H/B4s9K3MLsTMqOKlxSrzHTxB4= cloud.google.com/go/datastream v1.14.1 h1:j+y0lUKm9pbDjJn0YcWxPI/hXNGUQ80GE6yrFuJC/JA= cloud.google.com/go/datastream v1.14.1/go.mod h1:JqMKXq/e0OMkEgfYe0nP+lDye5G2IhIlmencWxmesMo= cloud.google.com/go/datastream v1.15.1 h1:7PKeDpksi8nbOR4gspmNokzsr0q/uRzDIt20bR3BtRs= cloud.google.com/go/datastream v1.15.1/go.mod h1:aV1Grr9LFon0YvqryE5/gF1XAhcau2uxN2OvQJPpqRw= cloud.google.com/go/datastream v1.20.0 h1:/Xv8hdolIN5SpMgxiiuDc8AncfEuXU9TWRhvQkngCq8= cloud.google.com/go/datastream v1.20.0/go.mod h1:uoWTtfP20W8MXuV2DPcl5zqnVsxQ9QEmmBHX858oYTQ= +cloud.google.com/go/datastream v1.21.0 h1:hrWGASBj85xs6wpnx9ifdSBYiRLhKDgQrA1HjeNYBhc= +cloud.google.com/go/datastream v1.21.0/go.mod h1:z9AlkQGdXqkeyO5HE+D6sYbOkLJYB4BCZpXFPX/1Vpo= cloud.google.com/go/deploy v1.27.2 h1:C0VqBhFyQFp6+xgPHZAD7LeRA4XGy5YLzGmPQ2NhlLk= cloud.google.com/go/deploy v1.27.2/go.mod h1:4NHWE7ENry2A4O1i/4iAPfXHnJCZ01xckAKpZQwhg1M= cloud.google.com/go/deploy v1.27.3 h1:QU8gLXsXDRqLyEWNrI6zJiVzuuOBX/WpMi4p0oexV+c= cloud.google.com/go/deploy v1.27.3/go.mod h1:7LFIYYTSSdljYRqY3n+JSmIFdD4lv6aMD5xg0crB5iw= cloud.google.com/go/deploy v1.32.0 h1:vA9yH8EEXOsq1caJpvkJl9wJYA9VU8xuU45V7iC9XHk= cloud.google.com/go/deploy v1.32.0/go.mod h1:lUG7maG/NkoTXmQ8G1mtcVymnbizfDJh6ER7vljVa/U= +cloud.google.com/go/deploy v1.33.0 h1:2PdZ8kbIztLVRMrIN/wHbalZeZZikQlxBptBFK+eEtM= +cloud.google.com/go/deploy v1.33.0/go.mod h1:QdF3plD8D5gV2RmkTXBB6cHrq490WlpFr1SChdOJO2Y= cloud.google.com/go/dialogflow v1.68.2 h1:bXpoqPRf37KKxB79PKr20B/TAU/Z5iA0FnB6C5N2jrA= cloud.google.com/go/dialogflow v1.68.2/go.mod h1:E0Ocrhf5/nANZzBju8RX8rONf0PuIvz2fVj3XkbAhiY= cloud.google.com/go/dialogflow v1.69.1 h1:R69CCEgx9RMHWjS2eP7aw5sE1Ajo5buQTzTdBe2o13w= @@ -358,6 +417,8 @@ cloud.google.com/go/dialogflow v1.76.0 h1:hP9GY9TSVlZ277IGCPQjem9RW1PDtfYJw98DkR cloud.google.com/go/dialogflow v1.76.0/go.mod h1:mdLkMmSCghfcP85X9dFBlirC1OssS65KE5hrrSz2GXY= cloud.google.com/go/dialogflow v1.82.0 h1:PKC7h47s036UsW4YxTV2aRCTOChEzMzioczRdlKSApk= cloud.google.com/go/dialogflow v1.82.0/go.mod h1:UtuiGOq9gAlTz9u4Vt+q1syMrx9ANQzTk+lC3WDdSOw= +cloud.google.com/go/dialogflow v1.84.0 h1:BymJ6nPotDcnphK2/D0VHMAfakjJlIV5GqxLHQg/Tos= +cloud.google.com/go/dialogflow v1.84.0/go.mod h1:OU8Lj1aw5Vr2hl9ifW+vsKnc2b4iJH+41U7nZ4whg3U= cloud.google.com/go/dlp v1.23.0 h1:3xWRKylXxhysaQaV+DLev1YcIywFUCc7yJEE6R7ZGDQ= cloud.google.com/go/dlp v1.23.0/go.mod h1:vVT4RlyPMEMcVHexdPT6iMVac3seq3l6b8UPdYpgFrg= cloud.google.com/go/dlp v1.25.0 h1:283+PJFk72SIj3apDTT/cHnKPBtuBUnduLBpbz1diFw= @@ -380,18 +441,24 @@ cloud.google.com/go/documentai v1.42.0 h1:FErf7mEjf3TBGiwcXQCsLrQ3mUqryTKa09NiO1 cloud.google.com/go/documentai v1.42.0/go.mod h1:CABOUzRNOuvb/QwJS2LS80Hpqbu3UW2afyRKTYuW7bo= cloud.google.com/go/documentai v1.48.0 h1:qodgYZJgA89EWNJeeWXWBe/5kq9C5cQhthJweI6Z6CE= cloud.google.com/go/documentai v1.48.0/go.mod h1:mGjfbNf0cqCHKgxMZZV7frbfoF9T2hKkU1h88QyOy3c= +cloud.google.com/go/documentai v1.49.0 h1:WLZOaWvK4NH7xMLxANsDgIdXugeqmVyYRCbVN1m87DQ= +cloud.google.com/go/documentai v1.49.0/go.mod h1:VyQA+SxPnCPlVLSJ5UcFx+LQm8JCzK7uUXdkOaAHvG8= cloud.google.com/go/domains v0.10.6 h1:TI+Aavwc31KD8huOquJz0ISchCq1zSEWc9M+JcPJyxc= cloud.google.com/go/domains v0.10.6/go.mod h1:3xzG+hASKsVBA8dOPc4cIaoV3OdBHl1qgUpAvXK7pGY= cloud.google.com/go/domains v0.10.7 h1:G3kUq0vKBMhyOj5GqAfEYbVuez05U+ENHZUAtrEp/pI= cloud.google.com/go/domains v0.10.7/go.mod h1:T3WG/QUAO/52z4tUPooKS8AY7yXaFxPYn1V3F0/JbNQ= cloud.google.com/go/domains v0.15.0 h1:X5RjcYzpsVkuTMZ3OfuSDIv9pBtMlDEk7XXunlfB518= cloud.google.com/go/domains v0.15.0/go.mod h1:BjoSVNc+LVwoHMnE2fxTQNzGLSWWb6f3a8VAN6+VjVk= +cloud.google.com/go/domains v0.16.0 h1:h+lcYPxyEulj5SYuH3+OwOsLZb4DTW11QX6nlxxXQ1I= +cloud.google.com/go/domains v0.16.0/go.mod h1:O5AhaEyUAgZC2X4M10nSu3dQt2cJLtbjhtrNrdeSPF8= cloud.google.com/go/edgecontainer v1.4.3 h1:9tfGCicvrki927T+hGMB0yYmwIbRuZY6JR1/awrKiZ0= cloud.google.com/go/edgecontainer v1.4.3/go.mod h1:q9Ojw2ox0uhAvFisnfPRAXFTB1nfRIOIXVWzdXMZLcE= cloud.google.com/go/edgecontainer v1.4.4 h1:6KTQo6Qf0iEtfPVotlG7orazEO1I93Ham0PMlkHYpdQ= cloud.google.com/go/edgecontainer v1.4.4/go.mod h1:yyNVHsCKtsX/0mqFdbljQw0Uo660q2dlMPaiqYiC2Tg= cloud.google.com/go/edgecontainer v1.9.0 h1:9S7YGenFNDVMoh5tulCbSniETQ+XxgjDDie/sEhdkw8= cloud.google.com/go/edgecontainer v1.9.0/go.mod h1:mZmgXuMGTGI6RUUTXsOZa+F2rFF21v0JPnuX7LQEqBE= +cloud.google.com/go/edgecontainer v1.10.0 h1:4WOjcIZRCB4ynxH9Zs1tteIcm2LD58VUCLLmIwLYUAc= +cloud.google.com/go/edgecontainer v1.10.0/go.mod h1:g4xb11IzVWa9peXNTlnNguKP8uJVvMK4zZeDlGS2Wus= cloud.google.com/go/errorreporting v0.3.2 h1:isaoPwWX8kbAOea4qahcmttoS79+gQhvKsfg5L5AgH8= cloud.google.com/go/errorreporting v0.3.2/go.mod h1:s5kjs5r3l6A8UUyIsgvAhGq6tkqyBCUss0FRpsoVTww= cloud.google.com/go/errorreporting v0.4.0 h1:uLcasn2hKpj6iSPvHrzRjkJcaNVaKx8yKQcP3VTS6aI= @@ -414,12 +481,16 @@ cloud.google.com/go/eventarc v1.18.0 h1:8WWG1/ogInYur1NQjML6EMHQ0ZBzAdMDGlUVpLD5 cloud.google.com/go/eventarc v1.18.0/go.mod h1:/6SDoqh5+9QNUqCX4/oQcJVK16fG/snHBSXu7lrJtO8= cloud.google.com/go/eventarc v1.23.0 h1:/EUAdoBWSlqQRbpQYTV2Msmg4esw3Mum3tEU7zkhLi4= cloud.google.com/go/eventarc v1.23.0/go.mod h1:tIJL0hoWtZXVa5MjcAep/4xB+AXz4AbqQV14ogX5VwU= +cloud.google.com/go/eventarc v1.25.0 h1:+XAJNEmxJIPZLMtzLYdUZN61Kmqx62hWLry4AsWaTwo= +cloud.google.com/go/eventarc v1.25.0/go.mod h1:ncY2NKHKiX+sUjIfxVozrivvmJQ4HWo2znxms7AxlP8= cloud.google.com/go/filestore v1.10.2 h1:LjoAyp9TvVNBns3sUUzPaNsQiGpR2BReGmTS3bUCuBE= cloud.google.com/go/filestore v1.10.2/go.mod h1:w0Pr8uQeSRQfCPRsL0sYKW6NKyooRgixCkV9yyLykR4= cloud.google.com/go/filestore v1.10.3 h1:3KZifUVTqGhNNv6MLeONYth1HjlVM4vDhaH+xrdPljU= cloud.google.com/go/filestore v1.10.3/go.mod h1:94ZGyLTx9j+aWKozPQ6Wbq1DuImie/L/HIdGMshtwac= cloud.google.com/go/filestore v1.15.0 h1:ZYFAnP4elMogIQAXFwPx4nKpcvY0dJOZV+zl2l50MGQ= cloud.google.com/go/filestore v1.15.0/go.mod h1:oD+PvCWu4HqfEdNv65yk2XaLIiP7h4AuAH9Ua5YBRTM= +cloud.google.com/go/filestore v1.16.0 h1:2GXy5jvq2oUX6HnCpx3GjzSqlcMz8K9gkHaF9HPcYNE= +cloud.google.com/go/filestore v1.16.0/go.mod h1:szr35omqptDEuXgBbJ8PdVdYM3lf/Md96kNufWr1tVs= cloud.google.com/go/firestore v1.18.0 h1:cuydCaLS7Vl2SatAeivXyhbhDEIR8BDmtn4egDhIn2s= cloud.google.com/go/firestore v1.18.0/go.mod h1:5ye0v48PhseZBdcl0qbl3uttu7FIEwEYVaWm0UIEOEU= cloud.google.com/go/firestore v1.20.0 h1:JLlT12QP0fM2SJirKVyu2spBCO8leElaW0OOtPm6HEo= @@ -428,18 +499,24 @@ cloud.google.com/go/firestore v1.21.0 h1:BhopUsx7kh6NFx77ccRsHhrtkbJUmDAxNY3uapW cloud.google.com/go/firestore v1.21.0/go.mod h1:1xH6HNcnkf/gGyR8udd6pFO4Z7GWJSwLKQMx/u6UrP4= cloud.google.com/go/firestore v1.22.0 h1:avooeboIq37vKXobrbPUFhFBxS/c3FqmWoX0xs8dO6E= cloud.google.com/go/firestore v1.22.0/go.mod h1:PaM4i7i7ruALSKmlpHXXZaPObcZw0W7ie5UOPr72iTU= +cloud.google.com/go/firestore v1.24.0 h1:x0Z3hrgjYgo2wI9whuBRQcNc2hYwzZDQy/7pkUXbXcs= +cloud.google.com/go/firestore v1.24.0/go.mod h1:5aojyjN4olKUnBZDCRWwM+NsdrrCX3t1qfyERZGOonM= cloud.google.com/go/functions v1.19.6 h1:vJgWlvxtJG6p/JrbXAkz83DbgwOyFhZZI1Y32vUddjY= cloud.google.com/go/functions v1.19.6/go.mod h1:0G0RnIlbM4MJEycfbPZlCzSf2lPOjL7toLDwl+r0ZBw= cloud.google.com/go/functions v1.19.7 h1:7LcOD18euIVGRUPaeCmgO6vfWSLNIsi6STWRQcdANG8= cloud.google.com/go/functions v1.19.7/go.mod h1:xbcKfS7GoIcaXr2FSwmtn9NXal1JR4TV6iYZlgXffwA= cloud.google.com/go/functions v1.24.0 h1:0nb8LMMABq/oChZg+ovRD5bsc/dNm5ti/aoHRZ9MoUs= cloud.google.com/go/functions v1.24.0/go.mod h1:t40GeqBAQNuqKlHCxmV/pxhyYJnImLcvRa3GBv4tAy0= +cloud.google.com/go/functions v1.25.0 h1:ndUtLkam3XF9b0t2zVACH9D/EgFBISbVuQFqRz/X58k= +cloud.google.com/go/functions v1.25.0/go.mod h1:b/tqakoKeAkj9RspEjqswWf5299Lkz9C/742QUD3OEk= cloud.google.com/go/gkebackup v1.8.0 h1:eBqOt61yEChvj7I/GDPBbdCCRdUPudD1qrQYfYWV3Ok= cloud.google.com/go/gkebackup v1.8.0/go.mod h1:FjsjNldDilC9MWKEHExnK3kKJyTDaSdO1vF0QeWSOPU= cloud.google.com/go/gkebackup v1.8.1 h1:gUgI3lZJYALZsHXE7YJOKI8bMpoAX/tF6jnNugvzT1g= cloud.google.com/go/gkebackup v1.8.1/go.mod h1:GAaAl+O5D9uISH5MnClUop2esQW4pDa2qe/95A4l7YQ= cloud.google.com/go/gkebackup v1.13.0 h1:QyeJc4XPqV0hzoAcAQIi8YMveT8eRI21oOK16qKItJo= cloud.google.com/go/gkebackup v1.13.0/go.mod h1:D2MDbHW4V/uKCmS9TnT8hNKX2tPkE/pWp9nSm0TQ9hY= +cloud.google.com/go/gkebackup v1.14.0 h1:li3BtGRis1QYrkLo8+Iq2wf5WbP9v3sz9VoUw8WqgaA= +cloud.google.com/go/gkebackup v1.14.0/go.mod h1:kaD4l/s0ONcb3L9iHC8PzG1XkC5ggPwA/KAl6yAyQGs= cloud.google.com/go/gkeconnect v0.12.4 h1:67/rnPmF/I1Wmf7jWyKH+z4OWjU8ZUI0Vmzxvmzf3KY= cloud.google.com/go/gkeconnect v0.12.4/go.mod h1:bvpU9EbBpZnXGo3nqJ1pzbHWIfA9fYqgBMJ1VjxaZdk= cloud.google.com/go/gkeconnect v0.12.5 h1:EFql3zRaFw74yATt5lf+mcPDqPZ4EeLvoIJ+0NaEkag= @@ -452,6 +529,8 @@ cloud.google.com/go/gkehub v0.16.0 h1:Jk5pAXG54FlQzTRXhuKyym/NzOgS8oWRs0XNatZYDf cloud.google.com/go/gkehub v0.16.0/go.mod h1:ADp27Ucor8v81wY+x/5pOxTorxkPj/xswH3AUpN62GU= cloud.google.com/go/gkehub v0.21.0 h1:Fvx6c94yYToZBlsYF7tBIt+LW1u6uY6WYK/h3Z6IZYI= cloud.google.com/go/gkehub v0.21.0/go.mod h1:xKePlMrI8LpKErzKMWdH/yQv+GDV60ypCNfTTdT+BN0= +cloud.google.com/go/gkehub v0.22.0 h1:gHKPoWQuWpd9dXLoC58dxwBvSYTNc5/fdYYcs3JUn1s= +cloud.google.com/go/gkehub v0.22.0/go.mod h1:WiXX1w9ZHwKZVUDwL//YQfjfWS7yE0I/ym3smZn9iwE= cloud.google.com/go/gkemulticloud v1.5.3 h1:334aZmOzIt3LVBpguCof8IHaLaftcZlx+L0TGBukYkY= cloud.google.com/go/gkemulticloud v1.5.3/go.mod h1:KPFf+/RcfvmuScqwS9/2MF5exZAmXSuoSLPuaQ98Xlk= cloud.google.com/go/gkemulticloud v1.5.4 h1:AKuOmr5QBCPLJCyZhBABP3lIz+h3jxAy53LVMrEuvlg= @@ -460,6 +539,8 @@ cloud.google.com/go/gkemulticloud v1.6.0 h1:m0FX9o7t7xVmSZhqzm/m8nEZn8LnC5Kh60Wg cloud.google.com/go/gkemulticloud v1.6.0/go.mod h1:bGpd4o/Z5Z/XFlaojkgdVisHRwb+fLJvUPzsmV0I9ok= cloud.google.com/go/gkemulticloud v1.11.0 h1:MTqEPjiNVY9bcliSfQR23HHaTPlfFinDh+4ARB5Gn14= cloud.google.com/go/gkemulticloud v1.11.0/go.mod h1:OtfHtgqOgDrXfcdFw8eUkCUI154Q51vvdqZYZV4c4qM= +cloud.google.com/go/gkemulticloud v1.12.0 h1:3LPj8ro7bPdZ0BlQJwby25G32rRezGLRdhNM6U3AxGs= +cloud.google.com/go/gkemulticloud v1.12.0/go.mod h1:vLNCxGah7pPIoNSX4Yx+hb8klqA0lzzXTWBSut9KzRo= cloud.google.com/go/grafeas v0.3.15 h1:lBjwKmhpiqOAFaE0xdqF8CqO74a99s8tUT5mCkBBxPs= cloud.google.com/go/grafeas v0.3.15/go.mod h1:irwcwIQOBlLBotGdMwme8PipnloOPqILfIvMwlmu8Pk= cloud.google.com/go/grafeas v0.3.16 h1:0R6n4WSJXQ3rHj6xl80hQjsniIPgGmFlHRLQmHZw9HU= @@ -485,6 +566,8 @@ cloud.google.com/go/ids v1.5.7 h1:V0pSk+KKW+5/AVpeQMhM9D1VI7aMZkayj5jddNETJos= cloud.google.com/go/ids v1.5.7/go.mod h1:N3ZQOIgIBwwOu2tzyhmh3JDT+kt8PcoKkn2BRT9Qe4A= cloud.google.com/go/ids v1.10.0 h1:uk4kW7UYUtIzlQigKreGKXq4HzbXrspjJ5SzUfPV6qg= cloud.google.com/go/ids v1.10.0/go.mod h1:uCSFrXfCnRUKBl5PdE/ZqBNp1+vKSKPWpdYGa61WjpQ= +cloud.google.com/go/ids v1.11.0 h1:394LkKIavhv/+oQVMtbXe9WQqCeY9wSFcHktqC9ZkLE= +cloud.google.com/go/ids v1.11.0/go.mod h1:+drdvU0pQ4x5uYiWCv364VOeIpTN/PETBrdR51D4Tjk= cloud.google.com/go/iot v1.8.6 h1:A3AhugnIViAZkC3/lHAQDaXBIk2ZOPBZS0XQCyZsjjc= cloud.google.com/go/iot v1.8.6/go.mod h1:MThnkiihNkMysWNeNje2Hp0GSOpEq2Wkb/DkBCVYa0U= cloud.google.com/go/iot v1.8.7 h1:PDUtxCzlFwFHODEFAgaGJy/Zv4tdvLbZ+lvZ1mKQXE4= @@ -505,7 +588,11 @@ cloud.google.com/go/lifesciences v0.10.7 h1:MO5aBahcYv7JeuCpHbg/11h7KL/BYt1+PpgH cloud.google.com/go/lifesciences v0.10.7/go.mod h1:v3AbTki9iWttEls/Wf4ag3EqeLRHofploOcpsLnu7iY= cloud.google.com/go/lifesciences v0.15.0 h1:sLkI7iAWGPkptWD5f6P9UX6JKiCp5gc4uoa07F8WykI= cloud.google.com/go/lifesciences v0.15.0/go.mod h1:FwS+QkqPdVWl4SmKUCFozFvsTVWTLH13HCKcwR/MR9U= +cloud.google.com/go/lifesciences v0.16.0 h1:T+7N3oVHK8a7BhX6HJLrBW8xEbMV9iY8Mb+aOl7r6+A= +cloud.google.com/go/lifesciences v0.16.0/go.mod h1:axEwGa3A63+vCXIis+0Zkseu8KecqtNoSn7x0zyjJfM= cloud.google.com/go/logging v1.13.0/go.mod h1:36CoKh6KA/M0PbhPKMq6/qety2DCAErbhXT62TuXALA= +cloud.google.com/go/logging v1.19.0 h1:NCqhdVUg3wQ8Cobdf16FDSuTGi3+6+hdSBHrY5TsR6Q= +cloud.google.com/go/logging v1.19.0/go.mod h1:i40NZCHC9Gqvod4yE+yQfDWwlgwW/SrshkkGibCHxcA= cloud.google.com/go/longrunning v0.6.6/go.mod h1:hyeGJUrPHcx0u2Uu1UFSoYZLn4lkMrccJig0t4FI7yw= cloud.google.com/go/longrunning v0.6.7/go.mod h1:EAFV3IZAKmM56TyiE6VAP3VoTzhZzySwI/YI1s/nRsY= cloud.google.com/go/longrunning v0.7.0/go.mod h1:ySn2yXmjbK9Ba0zsQqunhDkYi0+9rlXIwnoAf+h+TPY= @@ -515,6 +602,8 @@ cloud.google.com/go/managedidentities v1.7.7 h1:vC/q7D+97PZfb0UNf7r/+/clHauuaf1P cloud.google.com/go/managedidentities v1.7.7/go.mod h1:nwNlMxtBo2YJMvsKXRtAD1bL41qiCI9npS7cbqrsJUs= cloud.google.com/go/managedidentities v1.12.0 h1:tGderKWJBrOee9BtGul26gA6425tdbZXkbK0ZSkbAE4= cloud.google.com/go/managedidentities v1.12.0/go.mod h1:rm72jf/v//0NG73VQNZM1JlV2E95uhJymmSXlgi6hMA= +cloud.google.com/go/managedidentities v1.13.0 h1:ZzWkg3LSrIi9OdzCK3bxutexvw1dDDg9+tOMitbkOXw= +cloud.google.com/go/managedidentities v1.13.0/go.mod h1:lUYH5r6QEJTHqjgga0WFeiieqJ0iRwEuQSk20O41Vj0= cloud.google.com/go/maps v1.21.0 h1:El61AfMxC1sU/RU8Wzs9dkZEgltyunKM86aKF9aDlaE= cloud.google.com/go/maps v1.21.0/go.mod h1:cqzZ7+DWUKKbPTgqE+KuNQtiCRyg/o7WZF9zDQk+HQs= cloud.google.com/go/maps v1.23.0 h1:NGQM1vBXHZ7SgrlJ5q+KEoSw1B9pgYiFTNfuPa+2wOQ= @@ -527,6 +616,8 @@ cloud.google.com/go/maps v1.29.0 h1:iAlFpnckCAshFpmHPDUYpasXn0pH4OVMDfkb3jB/fDQ= cloud.google.com/go/maps v1.29.0/go.mod h1:FNATcM5ziB2TDE2IVWH4f/yeXc+SbUk1X+bmKjR8HEA= cloud.google.com/go/maps v1.35.0 h1:Noryf6HN6xKhNBQW0S0Pitc8Fc1L7ZjaIavLYAfneVE= cloud.google.com/go/maps v1.35.0/go.mod h1:HH1V8tduMn+b9oRMCdl3vok98uvHco/wElZXyJQ/9kU= +cloud.google.com/go/maps v1.37.0 h1:YoTRWohpNKeKxdQynorwHH69NZuUM06divZtqGPYjUA= +cloud.google.com/go/maps v1.37.0/go.mod h1:oalKFBmf2eHmdr3OvfEiiBlOakNlVitYYEPcM3TTUB4= cloud.google.com/go/mediatranslation v0.9.6 h1:SDGatA73TgZ8iCvILVXpk/1qhTK5DJyufUDEWgbmbV8= cloud.google.com/go/mediatranslation v0.9.6/go.mod h1:WS3QmObhRtr2Xu5laJBQSsjnWFPPthsyetlOyT9fJvE= cloud.google.com/go/mediatranslation v0.9.7 h1:JXbjms+JxgaWkj/YuaQm1OeCzuF+IZCDV17uUcZgFOU= @@ -539,14 +630,20 @@ cloud.google.com/go/memcache v1.11.7 h1:ZDIfIMZsKKPzwdbvTMOL1il0shX24J7B9DC+sEt4 cloud.google.com/go/memcache v1.11.7/go.mod h1:AU1jYlUqCihxapcJ1GGMtlMWDVhzjbfUWBXqsXa4rBg= cloud.google.com/go/memcache v1.16.0 h1:J6Iq97D6rlDMTJRnTjP3tttRrop8bZDCEKpbsmu0K1c= cloud.google.com/go/memcache v1.16.0/go.mod h1:y/rXhJiieCF742K958dY29fSfM+Y3wh2thRmWspU2Dg= +cloud.google.com/go/memcache v1.17.0 h1:l/co1jsmVLkNyl8uMqj1u1HIwd3AhCvGdHbEAIPb6jc= +cloud.google.com/go/memcache v1.17.0/go.mod h1:QQpFWgJvrFaQ6DgmitHejdbkLg8SJfHg5BzltKEWSt0= cloud.google.com/go/metastore v1.14.7 h1:dLm59AHHZCorveCylj7c2iWhkQsmMIeWTsV+tG/BXtY= cloud.google.com/go/metastore v1.14.7/go.mod h1:0dka99KQofeUgdfu+K/Jk1KeT9veWZlxuZdJpZPtuYU= cloud.google.com/go/metastore v1.14.8 h1:nfyUDD9AeKIs6btY5buQ1No0OVco20WpX9wIruL8UOA= cloud.google.com/go/metastore v1.14.8/go.mod h1:h1XI2LpD4ohJhQYn9TwXqKb5sVt6KSo47ft96SiFF1s= cloud.google.com/go/metastore v1.19.0 h1:oAFi3AkO9YZHoDYXo3cbLXlBS4PUUxe5/9kR+q4ta1g= cloud.google.com/go/metastore v1.19.0/go.mod h1:JGTjGdQ627m2ptDo86XsIKqzzZCk+GG41VEFD7ENsqs= +cloud.google.com/go/metastore v1.20.0 h1:kz0XBkP531MB1IR9jTY6/DB33UFP5Df1vFpPNgW1iPk= +cloud.google.com/go/metastore v1.20.0/go.mod h1:/bhZoizjM5iOrqWJeAFDw7c16C783wEftqofnJgKKYI= cloud.google.com/go/monitoring v1.24.0/go.mod h1:Bd1PRK5bmQBQNnuGwHBfUamAV1ys9049oEPHnn4pcsc= cloud.google.com/go/monitoring v1.24.2/go.mod h1:x7yzPWcgDRnPEv3sI+jJGBkwl5qINf+6qY4eq0I9B4U= +cloud.google.com/go/monitoring v1.30.0 h1:r/d+JUbyKmJ8b07iznuKfzVzrIXTWxHQ3lBRm3x2LlY= +cloud.google.com/go/monitoring v1.30.0/go.mod h1:htlUR0QWVMrjFzZmN4LGnMAve9xB/eduwjmINxVZ8RM= cloud.google.com/go/networkconnectivity v1.17.1 h1:RQcG1rZNCNV5Dn3tnINs4TYswDXk2hKH+85eh+JvoWU= cloud.google.com/go/networkconnectivity v1.17.1/go.mod h1:DTZCq8POTkHgAlOAAEDQF3cMEr/B9k1ZbpklqvHEBtg= cloud.google.com/go/networkconnectivity v1.19.1 h1:n0IzhdgSNzIKQygWwDV8yKRXkZpX3FsjCYFbO9iNHPU= @@ -557,6 +654,8 @@ cloud.google.com/go/networkconnectivity v1.21.0 h1:WS5XTNWyLODLO5YmftQmDIZtAa2DY cloud.google.com/go/networkconnectivity v1.21.0/go.mod h1:XC1UJ+tqBsLWz73dqrMc7kUvdTv0FIxtDGv6YntTBO0= cloud.google.com/go/networkconnectivity v1.26.0 h1:cnPha9p2FFBbxVQA0D5fRBQsROq6tVFmsDZfrGEObtY= cloud.google.com/go/networkconnectivity v1.26.0/go.mod h1:Uhzfk7NbiY6RNqV9XFvPWRji58+MkTYsTRfQ3EPtrGg= +cloud.google.com/go/networkconnectivity v1.27.0 h1:ieYJjbUn2L9ZSzEB9+qKgtoD3/l1/qAnId/Z6KE4ZBo= +cloud.google.com/go/networkconnectivity v1.27.0/go.mod h1:pCnczH2W/cnLSlnsnN+VzBoXlM81ZoUGuuacFBGThyw= cloud.google.com/go/networkmanagement v1.19.1 h1:ecukgArkYCVcK5w2h7WDDd+nHgmBAp9Bst7ClmVKz5A= cloud.google.com/go/networkmanagement v1.19.1/go.mod h1:icgk265dNnilxQzpr6rO9WuAuuCmUOqq9H6WBeM2Af4= cloud.google.com/go/networkmanagement v1.20.1 h1:W5zdnH332yJFADTXlsHWLVkiXLKGWHjrsg7vXLGd+Ws= @@ -569,6 +668,8 @@ cloud.google.com/go/networkmanagement v1.23.0 h1:PiteUY9H2u+wMgT1dQjD93PKI90o10R cloud.google.com/go/networkmanagement v1.23.0/go.mod h1:QTYCWp5UxUnU280SqF7AX/mf6NhsqKblmLeCALQmx5c= cloud.google.com/go/networkmanagement v1.28.0 h1:x4U4osf+1qmq7/FRIfjM781mJSeXhmjoDWrbhB4f3Mo= cloud.google.com/go/networkmanagement v1.28.0/go.mod h1:2YogSU3sD7LvtmWntUAuGARbFQmy3A0En3LrJr69jkU= +cloud.google.com/go/networkmanagement v1.30.0 h1:LqYm91vSQk3NM6fHjthseRh4xrDvhXIWQBsQY/Ksus8= +cloud.google.com/go/networkmanagement v1.30.0/go.mod h1:3SBf5T7jyGzw5jqJWE7TUDRhIl2E029jggbeoFEgt5E= cloud.google.com/go/networksecurity v0.10.6 h1:6b6fcCG9BFNcmtNO+VuPE04vkZb5TKNX9+7ZhYMgstE= cloud.google.com/go/networksecurity v0.10.6/go.mod h1:FTZvabFPvK2kR/MRIH3l/OoQ/i53eSix2KA1vhBMJec= cloud.google.com/go/networksecurity v0.10.7 h1:J5gdG7mHdRLrsyM7yy4nKFgbN8+geaOo/4Zpeh4DWrg= @@ -577,24 +678,32 @@ cloud.google.com/go/networksecurity v0.11.0 h1:+ahtCqEqwHw3a3UIeG21vT817xt9kkDDA cloud.google.com/go/networksecurity v0.11.0/go.mod h1:JLgDsg4tOyJ3eMO8lypjqMftbfd60SJ+P7T+DUmWBsM= cloud.google.com/go/networksecurity v0.16.0 h1:ONJ1NxuE30yoelpruxZmED1LPToWIGmUn8+jdJY4NHQ= cloud.google.com/go/networksecurity v0.16.0/go.mod h1:LMn10eRVf4K85PMF33yRoKAra7VhCOetxFcLDMh9A74= +cloud.google.com/go/networksecurity v0.19.0 h1:PzJnbVd0sS2+xDR9hnk31NAyqFt9qB8ohLn7XIsKIBA= +cloud.google.com/go/networksecurity v0.19.0/go.mod h1:VWDFX+stDgzZYDsCX1Wy/JO9Tlw7g/V1UHbiORVgqq0= cloud.google.com/go/notebooks v1.12.6 h1:nCfZwVihArMPP2atRoxRrXOXJ/aC9rAgpBQGCc2zpYw= cloud.google.com/go/notebooks v1.12.6/go.mod h1:3Z4TMEqAKP3pu6DI/U+aEXrNJw9hGZIVbp+l3zw8EuA= cloud.google.com/go/notebooks v1.12.7 h1:g5LTI1LHa/86abDTWd8nrq7/4qq8oFhVx1SmnNpZLVg= cloud.google.com/go/notebooks v1.12.7/go.mod h1:uR9pxAkKmlNloibMr9Q1t8WhIu4P2JeqJs7c064/0Mo= cloud.google.com/go/notebooks v1.17.0 h1:fiezRHPH/H4HatBxbzEQljlmDV8MBv2ffWs1Z6TyHhw= cloud.google.com/go/notebooks v1.17.0/go.mod h1:NScGIhfQCqLRIlVaUVbm595F6dhqiTl5XS1KaKgitKM= +cloud.google.com/go/notebooks v1.18.0 h1:BgxdQcoRSSjiyszWNOTu3fmXG/6Ks4W2sHgjx0BAke0= +cloud.google.com/go/notebooks v1.18.0/go.mod h1:fXU6A3TJ2YobFy6fxOr4tKZZ8QgTjdJAqDIykOB85Gk= cloud.google.com/go/optimization v1.7.6 h1:jDvIuSxDsXI2P7l2sYXm6CoX1YBIIT6Khm5m0hq0/KQ= cloud.google.com/go/optimization v1.7.6/go.mod h1:4MeQslrSJGv+FY4rg0hnZBR/tBX2awJ1gXYp6jZpsYY= cloud.google.com/go/optimization v1.7.7 h1:dMtxINB6G7wULbdm8nZ/x1NMa579Q+GfJc5gaN8VeDw= cloud.google.com/go/optimization v1.7.7/go.mod h1:OY2IAlX23o52qwMAZ0w65wibKuV12a4x6IHDTCq6kcU= cloud.google.com/go/optimization v1.11.0 h1:lh0CcgHOGEAilUn4xS4/gIsSZA4AmTqEhGXdpz6Z+N0= cloud.google.com/go/optimization v1.11.0/go.mod h1:qCWskZMcynh0GBsUrCP6oPwwnUhbwg5UcXvVM9hzOD8= +cloud.google.com/go/optimization v1.12.0 h1:Cd4DgujNhEMcjfaVLGqbjfDwVDnmS1Bc93cx9TE8ZYw= +cloud.google.com/go/optimization v1.12.0/go.mod h1:28gzCUmeCLcT4vctGEo71QF4b60TYkKQo5y8Gs2KPq8= cloud.google.com/go/orchestration v1.11.9 h1:PnlZ/O4R/eiounpxUkhI9ZXRMWbG7vFqxc6L6sR+31k= cloud.google.com/go/orchestration v1.11.9/go.mod h1:KKXK67ROQaPt7AxUS1V/iK0Gs8yabn3bzJ1cLHw4XBg= cloud.google.com/go/orchestration v1.11.10 h1:TVWDiZyvcflLFeTQH2GexHmtJ6iUSjzr0zsSiT338dA= cloud.google.com/go/orchestration v1.11.10/go.mod h1:tz7m1s4wNEvhNNIM3JOMH0lYxBssu9+7si5MCPw/4/0= cloud.google.com/go/orchestration v1.16.0 h1:aVakYx6wLQV8I8ZDydplEKzQ2+hTJ3Qh/lU5/mwijQA= cloud.google.com/go/orchestration v1.16.0/go.mod h1:H7MFVP8Z/dtml39nf43sWYPL/2o7J4tdSZAlJrBuqnQ= +cloud.google.com/go/orchestration v1.17.0 h1:dqf2HzqUi4CaaDiNfv6jVoZjz9lF0HWjyIEgccxPHMk= +cloud.google.com/go/orchestration v1.17.0/go.mod h1:Lf/Czqh4Jfy3IFpvDkKWjfjkYFI+tj6nAjq5ihivrq4= cloud.google.com/go/orgpolicy v1.15.0 h1:uQziDu3UKYk9ZwUgneZAW5aWxZFKgOXXsuVKFKh0z7Y= cloud.google.com/go/orgpolicy v1.15.0/go.mod h1:NTQLwgS8N5cJtdfK55tAnMGtvPSsy95JJhESwYHaJVs= cloud.google.com/go/orgpolicy v1.15.1 h1:0hq12wxNwcfUMojr5j3EjWECSInIuyYDhkAWXTomRhc= @@ -609,6 +718,8 @@ cloud.google.com/go/osconfig v1.16.0 h1:0L635e0OSdWylzE/v40Riko6p142PVmWL8Rt+9fb cloud.google.com/go/osconfig v1.16.0/go.mod h1:PRmLgZ1loD1hGaqnTBww1nETbqcqAvmTQOLYiIZ7Nvk= cloud.google.com/go/osconfig v1.21.0 h1:jpq0DNmjS4FkTbNILFdp03uZUQt8D2izpUtgtmSDieQ= cloud.google.com/go/osconfig v1.21.0/go.mod h1:BofnHqjjvu6lZQv/hqo2+rLCUiY4O6A9UYwwvVrSBjk= +cloud.google.com/go/osconfig v1.22.0 h1:r5lzneR9GNixJ96lZ6oIfJK9Cd5sKpnzZJe1LcGZf4w= +cloud.google.com/go/osconfig v1.22.0/go.mod h1:bUL0FaSR2ahPcFRRYnd6a0LyUzsQYIdUpBq8Tmxg8fE= cloud.google.com/go/oslogin v1.14.6 h1:BDKVcxo1OO4ZT+PbuFchZjnbrlUGfChilt6+pITY1VI= cloud.google.com/go/oslogin v1.14.6/go.mod h1:xEvcRZTkMXHfNSKdZ8adxD6wvRzeyAq3cQX3F3kbMRw= cloud.google.com/go/oslogin v1.14.7 h1:YQ8P/+MLwH0tpENYU9QOgwKQxe8DYfAKxIfm6y+OBtA= @@ -627,12 +738,16 @@ cloud.google.com/go/policytroubleshooter v1.11.7 h1:Bbj1EiVh96u9mfO2p+JNoHrvvyC0 cloud.google.com/go/policytroubleshooter v1.11.7/go.mod h1:JP/aQ+bUkt4Gz6lQXBi/+A/6nyNRZ0Pvxui5Xl9ieyk= cloud.google.com/go/policytroubleshooter v1.15.0 h1:nHNbD/2XYM5krsBN9C1W+qSFOlOcbbjct51LXCM1qig= cloud.google.com/go/policytroubleshooter v1.15.0/go.mod h1:yNuROjN6h+2/TE2JOvBBJMjYIjC6j0UYHq8f2kVHlA4= +cloud.google.com/go/policytroubleshooter v1.16.0 h1:V3CcqzY6FhY2c2idRGZv0Ef4mFinLnXnfDFtAhxyTfM= +cloud.google.com/go/policytroubleshooter v1.16.0/go.mod h1:FZg3IW3exF6wc9eO/iBYijsGqiiCzc9mjZhsxgATXYA= cloud.google.com/go/privatecatalog v0.10.7 h1:R951ikhxIanXEijBCu0xnoUAOteS5m/Xplek0YvsNTE= cloud.google.com/go/privatecatalog v0.10.7/go.mod h1:Fo/PF/B6m4A9vUYt0nEF1xd0U6Kk19/Je3eZGrQ6l60= cloud.google.com/go/privatecatalog v0.10.8 h1:yOdy85WDvSCPxAMixkhs5X0Z96D74kosgOTp7aJEYvU= cloud.google.com/go/privatecatalog v0.10.8/go.mod h1:BkLHi+rtAGYBt5DocXLytHhF0n6F03Tegxgty40Y7aA= cloud.google.com/go/privatecatalog v0.15.0 h1:sQSFvJIXKM9RYFK3fPIBDSykC5I2TOmCZMXz9Q3DFsY= cloud.google.com/go/privatecatalog v0.15.0/go.mod h1:av2b5Rv+oG5ORxUqGlCAYO9s4pXjgc6q2qO9nkTcqT8= +cloud.google.com/go/privatecatalog v0.16.0 h1:SBhcVNVhgEGT1edcfSFY/KSiy2xSQTSQUpY12f10rmw= +cloud.google.com/go/privatecatalog v0.16.0/go.mod h1:Dq1bSHRRaDqFr7Rb7UntXVjh1reeY6YdzYicL0EPTrM= cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I= cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw= cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA= @@ -641,6 +756,8 @@ cloud.google.com/go/pubsub v1.50.1/go.mod h1:6YVJv3MzWJUVdvQXG081sFvS0dWQOdnV+oT cloud.google.com/go/pubsub/v2 v2.0.0/go.mod h1:0aztFxNzVQIRSZ8vUr79uH2bS3jwLebwK6q1sgEub+E= cloud.google.com/go/pubsublite v1.8.2 h1:jLQozsEVr+c6tOU13vDugtnaBSUy/PD5zK6mhm+uF1Y= cloud.google.com/go/pubsublite v1.8.2/go.mod h1:4r8GSa9NznExjuLPEJlF1VjOPOpgf3IT6k8x/YgaOPI= +cloud.google.com/go/pubsublite v1.10.0 h1:kqNk9e0gt0/Eu6lChjDlgu4B8k/399FZFpAU4LNq4Co= +cloud.google.com/go/pubsublite v1.10.0/go.mod h1:o9NVNBY4m8LubZqRCJtBdxpjP8DAsYizsxC6Z1vI7Dk= cloud.google.com/go/recaptchaenterprise/v2 v2.20.4 h1:P4QMryKcWdi4LIe1Sx0b2ZOAQv5gVfdzPt2peXcN32Y= cloud.google.com/go/recaptchaenterprise/v2 v2.20.4/go.mod h1:3H8nb8j8N7Ss2eJ+zr+/H7gyorfzcxiDEtVBDvDjwDQ= cloud.google.com/go/recaptchaenterprise/v2 v2.20.5 h1:Q2CcYGxcvnvng2q3o1SaOpV+rjE/AbFVYGTJomxlG4g= @@ -655,24 +772,32 @@ cloud.google.com/go/recommendationengine v0.9.7 h1:NH89CyKQP8e98kpdKLwV0jXkQGzSE cloud.google.com/go/recommendationengine v0.9.7/go.mod h1:snZ/FL147u86Jqpv1j95R+CyU5NvL/UzYiyDo6UByTM= cloud.google.com/go/recommendationengine v0.14.0 h1:kQ+PcZcQBv+FMlZRTp29UYvl3VD5/jsU0MsNgOFTw3I= cloud.google.com/go/recommendationengine v0.14.0/go.mod h1:UP9cN46tDpZ/N57eDYIWeIRHjMOchtiIyjWjV0Dvr3k= +cloud.google.com/go/recommendationengine v0.15.0 h1:G0tmQXp67YDaduvde7AX+cIdTtDCo2PBMeqv8SBOpnw= +cloud.google.com/go/recommendationengine v0.15.0/go.mod h1:Yx45rCF3A5fLSeXxSkXOCTXSBDBogrQnR7kUTJHwYxw= cloud.google.com/go/recommender v1.13.5 h1:cIsyRKGNw4LpCfY5c8CCQadhlp54jP4fHtP+d5Sy2xE= cloud.google.com/go/recommender v1.13.5/go.mod h1:v7x/fzk38oC62TsN5Qkdpn0eoMBh610UgArJtDIgH/E= cloud.google.com/go/recommender v1.13.6 h1:ZVZg4wr1G7yzjIPcYUNSUJAaz9+2o78rmBU4QJgC7kg= cloud.google.com/go/recommender v1.13.6/go.mod h1:y5/5womtdOaIM3xx+76vbsiA+8EBTIVfWnxHDFHBGJM= cloud.google.com/go/recommender v1.18.0 h1:NgL7zkQ4lSUQIe+aS5dMmDcOO6+DltH6E8bFD7wMjwk= cloud.google.com/go/recommender v1.18.0/go.mod h1:INRBLfBQJCrgPqjBVFht4OjaFq/WhB/c5V1sqBOdX8g= +cloud.google.com/go/recommender v1.19.0 h1:fJ6oO/7Ta/yzfRHcuJUln9iCeo6FDb5yIi2L7eLldzs= +cloud.google.com/go/recommender v1.19.0/go.mod h1:LRh+1HJjLx2kDE3S65AIlG/lvwA0llEFWYPD/QtgoaU= cloud.google.com/go/redis v1.18.2 h1:JlHLceAOILEmbn+NIS7l+vmUKkFuobLToCWTxL7NGcQ= cloud.google.com/go/redis v1.18.2/go.mod h1:q6mPRhLiR2uLf584Lcl4tsiRn0xiFlu6fnJLwCORMtY= cloud.google.com/go/redis v1.18.3 h1:6LI8zSt+vmE3WQ7hE5GsJ13CbJBLV1qUw6B7CY31Wcw= cloud.google.com/go/redis v1.18.3/go.mod h1:x8HtXZbvMBDNT6hMHaQ022Pos5d7SP7YsUH8fCJ2Wm4= cloud.google.com/go/redis v1.23.0 h1:y/NCxLQR46TQufJNjgINfWsRjCxkgClU37mMf/D1EE4= cloud.google.com/go/redis v1.23.0/go.mod h1:EUlUT24BAL6LsE1f/N9Bg3LhRCfH+LzwLGbst3KuZRw= +cloud.google.com/go/redis v1.24.0 h1:lV6xFUF7t3fm2ZRFjB4IVNkznG9EVwetYVJYwzmW8pc= +cloud.google.com/go/redis v1.24.0/go.mod h1:ebtw9WLFKswecHO2ifNykuteNJNwoPqMCHz4UI11kF4= cloud.google.com/go/resourcemanager v1.10.6 h1:LIa8kKE8HF71zm976oHMqpWFiaDHVw/H1YMO71lrGmo= cloud.google.com/go/resourcemanager v1.10.6/go.mod h1:VqMoDQ03W4yZmxzLPrB+RuAoVkHDS5tFUUQUhOtnRTg= cloud.google.com/go/resourcemanager v1.10.7 h1:oPZKIdjyVTuag+D4HF7HO0mnSqcqgjcuA18xblwA0V0= cloud.google.com/go/resourcemanager v1.10.7/go.mod h1:rScGkr6j2eFwxAjctvOP/8sqnEpDbQ9r5CKwKfomqjs= cloud.google.com/go/resourcemanager v1.15.0 h1:OwcTLrKaly0SMPoYHssPG4FBzRF0tyimeySOFD/YPJ0= cloud.google.com/go/resourcemanager v1.15.0/go.mod h1:ve0VNxPoDU6XxDuEMCjkineb0YzXQXx3mOWwnNckGDE= +cloud.google.com/go/resourcemanager v1.16.0 h1:aDf2RuuQ1Z+kbHHDzHyJNMHPGRFK+cMYfZIhWRaeRX0= +cloud.google.com/go/resourcemanager v1.16.0/go.mod h1:Hn4HPkLRnTuiUhFEFJg736Brt7BwlS84xYU06sc3STc= cloud.google.com/go/resourcesettings v1.8.3 h1:13HOFU7v4cEvIHXSAQbinF4wp2Baybbq7q9FMctg1Ek= cloud.google.com/go/resourcesettings v1.8.3/go.mod h1:BzgfXFHIWOOmHe6ZV9+r3OWfpHJgnqXy8jqwx4zTMLw= cloud.google.com/go/retail v1.21.0 h1:8jgWgtAg1mk91WmaoWRTlL9CcvazPwqZ3YT9n6Gva9U= @@ -685,6 +810,8 @@ cloud.google.com/go/retail v1.26.0 h1:yOoyJs/IlLmohXzgDgF9N8xQYbJKIKtCw4oGAoYZpN cloud.google.com/go/retail v1.26.0/go.mod h1:gMfh6s174Mvy1rK4g50J9TH5sRim8px+Krml25kdrqo= cloud.google.com/go/retail v1.31.0 h1:nJnfVzX+GOIe+PwDNSYG080ydirDzoD53z+c3y7ZzpU= cloud.google.com/go/retail v1.31.0/go.mod h1:sfq/cT+gfSLuURf/mdVAw5n0pav3hxSP1rT8RfL7Qxk= +cloud.google.com/go/retail v1.32.0 h1:u27GLYlnNsQrqzcvSlpZK0L6Ig8k3PEuXGkUdNFCOn4= +cloud.google.com/go/retail v1.32.0/go.mod h1:t9w9mBarD59BnFHTST2LoiCP5608ZlEfniHLgA6OoH0= cloud.google.com/go/run v1.10.0 h1:CDhz0PPzI/cVpmNFyHe3Yp21jNpiAqtkfRxuoLi+JU0= cloud.google.com/go/run v1.10.0/go.mod h1:z7/ZidaHOCjdn5dV0eojRbD+p8RczMk3A7Qi2L+koHg= cloud.google.com/go/run v1.12.0 h1:l4tpqhzJ75uOugXl2BQ15uEM5gLamVH5M70tBv70ZCU= @@ -695,6 +822,8 @@ cloud.google.com/go/run v1.15.0 h1:4cwyNv9SUQEsQOf5/DfPKyMWYSA52p38/o119BgMhO4= cloud.google.com/go/run v1.15.0/go.mod h1:rgFHMdAopLl++57vzeqA+a1o2x0/ILZnEacRD6nC0EA= cloud.google.com/go/run v1.21.0 h1:gQJUy0//XNXXpiZs42KlbLPhbycxbpS2QymGRFlPXv4= cloud.google.com/go/run v1.21.0/go.mod h1:Z5wHbyFirI8XU48EPs5XJf/qmVm1SXZEhuS8EvZOuQU= +cloud.google.com/go/run v1.22.0 h1:U56fxJWdrT+yjo4S/Vrtw5m69NdNL11Cyv9jX2JOi1s= +cloud.google.com/go/run v1.22.0/go.mod h1:Wo0aTNrqfftGmbxPPraeOxSUDUZ2c7IVNg2dk8Qm1Bs= cloud.google.com/go/scheduler v1.11.7 h1:zkMEJ0UbEJ3O7NwEUlKLIp6eXYv1L7wHjbxyxznajKM= cloud.google.com/go/scheduler v1.11.7/go.mod h1:gqYs8ndLx2M5D0oMJh48aGS630YYvC432tHCnVWN13s= cloud.google.com/go/scheduler v1.11.8 h1:BoXY2BvBsaRw3ggVMzC9tborZqJBu+NcJcD9PqeC5Kc= @@ -717,6 +846,8 @@ cloud.google.com/go/security v1.19.2 h1:cF3FkCRRbRC1oXuaGZFl3qU2sdu2gP3iOAHKzL5y cloud.google.com/go/security v1.19.2/go.mod h1:KXmf64mnOsLVKe8mk/bZpU1Rsvxqc0Ej0A6tgCeN93w= cloud.google.com/go/security v1.24.0 h1:0xkc4JbFF6xCzMRpr5J5U/0mojdRQ6N0Uk0feGctViI= cloud.google.com/go/security v1.24.0/go.mod h1:XaB3p0SE7v2bBitsLBb1hM6R8/oI/k/IujpXFJalFK0= +cloud.google.com/go/security v1.26.0 h1:xp/htSPHpyqo225Ju6PjNS9jxfcOEQMxnF229Oze62g= +cloud.google.com/go/security v1.26.0/go.mod h1:nd0i5OHXtJduMt0n6UnEojy7fiTfnfj/PSDeD7LAD+c= cloud.google.com/go/securitycenter v1.36.2 h1:hLA58IBYmWrNiXDIONvuCUQ4sHLVPy8JvDo2j1wSYCw= cloud.google.com/go/securitycenter v1.36.2/go.mod h1:80ocoXS4SNWxmpqeEPhttYrmlQzCPVGaPzL3wVcoJvE= cloud.google.com/go/securitycenter v1.38.0 h1:sU+tckApsBLZHrTALVvetgz4XcPsgbL0TXREjcPM3qM= @@ -725,6 +856,8 @@ cloud.google.com/go/securitycenter v1.38.1 h1:D9zpeguY4frQU35GBw8+M6Gw79CiuTF9iV cloud.google.com/go/securitycenter v1.38.1/go.mod h1:Ge2D/SlG2lP1FrQD7wXHy8qyeloRenvKXeB4e7zO6z0= cloud.google.com/go/securitycenter v1.44.0 h1:/jinB3GeXuNkWfrzK1EdWR+kD4J0z0YGyEe52+gPIoM= cloud.google.com/go/securitycenter v1.44.0/go.mod h1:7BMMbSTAddVfiE+HrC8tKS6SuRkyK7FRPlkpAZBRV3U= +cloud.google.com/go/securitycenter v1.45.0 h1:k5uzLtTSxnh4fwG+8nIcuwDjEE3J+6xe2wcZ72cys7s= +cloud.google.com/go/securitycenter v1.45.0/go.mod h1:7mAlzsCsKlEVmciAFORl431laDGpoKGFkSQndAzFs30= cloud.google.com/go/servicedirectory v1.12.6 h1:pl/KUNvFzlXpxgnPgzQjyTQQcv5WsQ97zCHaPrLQlYA= cloud.google.com/go/servicedirectory v1.12.6/go.mod h1:OojC1KhOMDYC45oyTn3Mup08FY/S0Kj7I58dxUMMTpg= cloud.google.com/go/servicedirectory v1.12.7 h1:je2yZlVcVFI/TshPXjjF9ZAlWedj0s5EbO2kozJrzBo= @@ -737,6 +870,8 @@ cloud.google.com/go/shell v1.8.7 h1:K1C9sh9EuNNhGpyCoqRdeudcU9zmfYTA95bhF5cokK8= cloud.google.com/go/shell v1.8.7/go.mod h1:OTke7qc3laNEW5Jr5OV9VR3IwU5x5VqGOE6705zFex4= cloud.google.com/go/shell v1.12.0 h1:eDwvv8ya1BCHCwHCzEIYp/9maLhGCco0LIjeGT4evBA= cloud.google.com/go/shell v1.12.0/go.mod h1:TivWrVriy6xQ0wBjNJJridJgODZz8zXUEW2u48kynzY= +cloud.google.com/go/shell v1.13.0 h1:vJ/g4BXCwBRMmUxHoNjdbE0DRh5Aysw/+vrZ/aPl0yc= +cloud.google.com/go/shell v1.13.0/go.mod h1:9WWf3xHQUElP5fL/lB9IJ/MMMnN2W/T86cBp+pXFFWo= cloud.google.com/go/spanner v1.82.0 h1:w9uO8RqEoBooBLX4nqV1RtgudyU2ZX780KTLRgeVg60= cloud.google.com/go/spanner v1.82.0/go.mod h1:BzybQHFQ/NqGxvE/M+/iU29xgutJf7Q85/4U9RWMto0= cloud.google.com/go/spanner v1.85.1 h1:cJx1ZD//C2QIfFQl8hSTn4twL8amAXtnayyflRIjj40= @@ -761,6 +896,8 @@ cloud.google.com/go/speech v1.30.0 h1:R+KGIbRMrj8jA4U6Qea8hqCMsAEdg576ShNsmRr4gc cloud.google.com/go/speech v1.30.0/go.mod h1:F2+NJujR8uzDLd6bwy5kgtVycxvEq06nzvzz5eQ/gMo= cloud.google.com/go/speech v1.35.0 h1:jxWycO5+PfhBWxqnuJNDjNMi85zRK2Jcb4CVhOz6JcA= cloud.google.com/go/speech v1.35.0/go.mod h1:shnf33sZbGnQQZyek1fdLOR5rRKV6D3jsNqpqyijvj8= +cloud.google.com/go/speech v1.36.0 h1:ZcmRKcY02vkF3o/Eaa9yI3i1baNFTF4ctyDZVRfnNS0= +cloud.google.com/go/speech v1.36.0/go.mod h1:tiSA8MiX49o1ngq5Ww2JFTvfjKxtAuBKY/UIH6coCPg= cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw= cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos= cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk= @@ -776,12 +913,16 @@ cloud.google.com/go/storagetransfer v1.13.1 h1:Sjukr1LtUt7vLTHNvGc2gaAqlXNFeDFRI cloud.google.com/go/storagetransfer v1.13.1/go.mod h1:S858w5l383ffkdqAqrAA+BC7KlhCqeNieK3sFf5Bj4Y= cloud.google.com/go/storagetransfer v1.18.0 h1:Y8kA7TiPPjiQH7Xsuf2KlBAJd7Jcn5J8aR5ABO81p/g= cloud.google.com/go/storagetransfer v1.18.0/go.mod h1:AbGutEym/KNasoiDpSj/CYbigp5yhgosSgwlhGvQNs4= +cloud.google.com/go/storagetransfer v1.19.0 h1:jh+3SegMUulEhuJbkADCv8lmfRC8/pCpxja4oex2Ns8= +cloud.google.com/go/storagetransfer v1.19.0/go.mod h1:sy4ImXynHkm9CKmbILtmzLN36PHh7JOhUTpqXf5SvMs= cloud.google.com/go/talent v1.8.3 h1:wDP+++O/P1cTJBMkYlSY46k0a6atSoyO+UkBGuU9+Ao= cloud.google.com/go/talent v1.8.3/go.mod h1:oD3/BilJpJX8/ad8ZUAxlXHCslTg2YBbafFH3ciZSLQ= cloud.google.com/go/talent v1.8.4 h1:1kJJ+WCY5LZ1A4rCa32zKh3N2xT3I8koiS63+vV0WC4= cloud.google.com/go/talent v1.8.4/go.mod h1:3yukBXUTVFNyKcJpUExW/k5gqEy8qW6OCNj7WdN0MWo= cloud.google.com/go/talent v1.13.0 h1:/nZYKG20ZHfZDr7ikRuDnssxk8fuaDxGR+KH3iB4gak= cloud.google.com/go/talent v1.13.0/go.mod h1:GSwli9V25WQdzeuJDJWH9TlQmA8lPFn7yKsxowdxW9Y= +cloud.google.com/go/talent v1.14.0 h1:dUNSwgBUFlDEMHJpmntEvDeLkPQAoEzBrvDbNfS/UeQ= +cloud.google.com/go/talent v1.14.0/go.mod h1:jieYQngp1YqRtqV2t92w3LTrjuLV05kMM4BZMUUneaw= cloud.google.com/go/texttospeech v1.13.0 h1:oWWFQp0yFl4EJOr3opDkKH9304wUsZjgPjrTDS6S1a8= cloud.google.com/go/texttospeech v1.13.0/go.mod h1:g/tW/m0VJnulGncDrAoad6WdELMTes8eb77Idz+4HCo= cloud.google.com/go/texttospeech v1.14.0 h1:ArOelKEIHCA0St/svzpl668gittbg9CZ1+DYCBRvJmQ= @@ -790,12 +931,16 @@ cloud.google.com/go/texttospeech v1.16.0 h1:Ra4w+6qmaeb12ozlPBqGw8Jzdge1yfzhvZgc cloud.google.com/go/texttospeech v1.16.0/go.mod h1:AeSkoH3ziPvapsuyI07TWY4oGxluAjntX+pF4PJ2jy0= cloud.google.com/go/texttospeech v1.21.0 h1:u1Zvij2JgV3Vci3M2YrotjqnmW4px0uhoVoW8Vv6IP0= cloud.google.com/go/texttospeech v1.21.0/go.mod h1:p/UVJILAo/S5vsJaWZVdDRzNzA7wXIA+hTACvpMeOBk= +cloud.google.com/go/texttospeech v1.22.0 h1:xGqQv2LB4nttaBNMepCm19EQ0Sk0YdjzPvklDU+LYsI= +cloud.google.com/go/texttospeech v1.22.0/go.mod h1:bAksATiWPKaw8r8wVgANa4GkVdsyFE4y9ulRzKyuJec= cloud.google.com/go/tpu v1.8.3 h1:S4Ptq+yFIPNLEzQ/OQwiIYDNzk5I2vYmhf0SmFQOmWo= cloud.google.com/go/tpu v1.8.3/go.mod h1:Do6Gq+/Jx6Xs3LcY2WhHyGwKDKVw++9jIJp+X+0rxRE= cloud.google.com/go/tpu v1.8.4 h1:5DDheA1f7yZ/KUbVT/9lL+Yhgd3IqHDSVVrSqDVkAFY= cloud.google.com/go/tpu v1.8.4/go.mod h1:ul0cyWSHr6jHGZYElZe6HvQn35VY93RAlwpDiSBRnPA= cloud.google.com/go/tpu v1.13.0 h1:OAtRW+A/+bTLsPS5/trnK7Cz1GceMa2ZlLQzP/ZbSTg= cloud.google.com/go/tpu v1.13.0/go.mod h1:F5gT5BL22Dhsr05JLHdMjAjj+wcTn3Xtuu4jvq9yFug= +cloud.google.com/go/tpu v1.14.0 h1:u2f8Lhezy6piu0BB3+4pTLw9xyOeU5n8PCgWjbjCitk= +cloud.google.com/go/tpu v1.14.0/go.mod h1:1pggTTG5npfxea6vYjyl60Fg09VgbM7efBgVjnFZjpo= cloud.google.com/go/trace v1.11.6/go.mod h1:GA855OeDEBiBMzcckLPE2kDunIpC72N+Pq8WFieFjnI= cloud.google.com/go/translate v1.12.5 h1:QPMNi4WCtHwc2PPfxbyUMwdN/0+cyCGLaKi2tig41J8= cloud.google.com/go/translate v1.12.5/go.mod h1:o/v+QG/bdtBV1d1edmtau0PwTfActvxPk/gtqdSDBi4= @@ -805,6 +950,8 @@ cloud.google.com/go/translate v1.12.7 h1:aSxMbfJ3MVmEdQzu5jGXmPPxCAb1ySsor2yBMCI cloud.google.com/go/translate v1.12.7/go.mod h1:wwJp14NZyWvcrFANhIXutXj0pOBkYciBHwSlUOykcjI= cloud.google.com/go/translate v1.17.0 h1:6ecjspRHAOHU+x+e4HOK/2o+bzw7KHwu//eyLVf4TuM= cloud.google.com/go/translate v1.17.0/go.mod h1:3mErnHTQBu9yeLiL35K0HBBuaM6Vk2fD/vyWFz790VU= +cloud.google.com/go/translate v1.18.0 h1:2dxWfGMG7y5vobxD2pPyngnfPPIk4MYdNB9QK5sEbuc= +cloud.google.com/go/translate v1.18.0/go.mod h1:aRVIE+P+7fngk8HwwFAgis5QA7wphGpKrFpdNoWtGCM= cloud.google.com/go/video v1.24.0 h1:KTB2BEXjGm2K/JcKxQXEgx3nSoMTByepnPZa4kln064= cloud.google.com/go/video v1.24.0/go.mod h1:h6Bw4yUbGNEa9dH4qMtUMnj6cEf+OyOv/f2tb70G6Fk= cloud.google.com/go/video v1.26.0 h1:EbzycFpb8jCbAhtfdQoVRxqVv6pXXabTdsGvUNoXqos= @@ -813,18 +960,24 @@ cloud.google.com/go/video v1.27.1 h1:Hp+2AeM7b3AagdHcyh2820UTzSbGyqpFJVMu0nHbBcw cloud.google.com/go/video v1.27.1/go.mod h1:xzfAC77B4vtnbi/TT3UUxEjCa/+Ehy5EA8w470ytOig= cloud.google.com/go/video v1.32.0 h1:9Us/tkhNRg3WY9wIrVC3Jcs1P0nXKE4XnS1zYJ3xTTY= cloud.google.com/go/video v1.32.0/go.mod h1:KxDL728ZzH+FJwtEb9XkiLTETW5bI37hTWbJiRYeXkk= +cloud.google.com/go/video v1.33.0 h1:zPA41kNscRrJeRo/xD73TjNCFAnRDnP9ViNPuWARVeg= +cloud.google.com/go/video v1.33.0/go.mod h1:hEx8TNpQT6kdjMVsywePvT8BCb63Ee3F/R0GRa9wnzo= cloud.google.com/go/videointelligence v1.12.6 h1:heq7jEO39sH5TycBh8TGFJ827XCxK0tIWatmBY/n0jI= cloud.google.com/go/videointelligence v1.12.6/go.mod h1:/l34WMndN5/bt04lHodxiYchLVuWPQjCU6SaiTswrIw= cloud.google.com/go/videointelligence v1.12.7 h1:FisUrSZ+y3oLuGdlFQQgZoNTDm7FAfb2hwSTsSqX+9g= cloud.google.com/go/videointelligence v1.12.7/go.mod h1:XAk5hCMY+GihxJ55jNoMdwdXSNZnCl3wGs2+94gK7MA= cloud.google.com/go/videointelligence v1.16.0 h1:WSvC2OI6Su3ulwz0aS7qOVQHO7ZtohUyI6GMqvETY/o= cloud.google.com/go/videointelligence v1.16.0/go.mod h1:mmX1JpIWzwozaigrdRNjikZc3aFLNHFKh+OFwAdfiW4= +cloud.google.com/go/videointelligence v1.17.0 h1:cd+s4jLMS59xiYLT6eQ6PVKP8R0PskgifXDa7YwMUUU= +cloud.google.com/go/videointelligence v1.17.0/go.mod h1:Phxz7AQpvXoOvz+KrrOZEJRo4CDgYXMDVDqhCtdF1jc= cloud.google.com/go/vision/v2 v2.9.5 h1:UJZ0H6UlOaYKgCn6lWG2iMAOJIsJZLnseEfzBR8yIqQ= cloud.google.com/go/vision/v2 v2.9.5/go.mod h1:1SiNZPpypqZDbOzU052ZYRiyKjwOcyqgGgqQCI/nlx8= cloud.google.com/go/vision/v2 v2.9.6 h1:9UtOINPF8p9VACQ6KAyR/ZtkpuBHGmJsprutYupDcN0= cloud.google.com/go/vision/v2 v2.9.6/go.mod h1:lJC+vP15D5znJvHQYjEoTKnpToX1L93BUlvBmzM0gyg= cloud.google.com/go/vision/v2 v2.14.0 h1:l4CjEOm9veghGSutx79p+WG6vI6/5DPjRsAasmi9zX4= cloud.google.com/go/vision/v2 v2.14.0/go.mod h1:ODlLCajJOq4t8thoi1uVvbnfIfix73HsYWhZuIveagQ= +cloud.google.com/go/vision/v2 v2.15.0 h1:aTR1vj4++WtS9HD6YdGuoaYygMTJ873WaoV9sYjlQCc= +cloud.google.com/go/vision/v2 v2.15.0/go.mod h1:DUdjdFkXqPvEoPC4WDYFvYCn0LlAZ4vVz29A0bXvW90= cloud.google.com/go/vmmigration v1.8.6 h1:68hOQDhs1DOITrCrhritrwr8xy6s8QMdwDyMzMiFleU= cloud.google.com/go/vmmigration v1.8.6/go.mod h1:uZ6/KXmekwK3JmC8PzBM/cKQmq404TTfWtThF6bbf0U= cloud.google.com/go/vmmigration v1.9.0 h1:iekipb1hzN4qxVaFKvd4iM3IjMGDuu/CHb2PRfj5GCk= @@ -835,24 +988,32 @@ cloud.google.com/go/vmmigration v1.10.0 h1:6AvttGxASQTiuIsNKUKOKsRiQG4qTMOY4KMyB cloud.google.com/go/vmmigration v1.10.0/go.mod h1:LDztCWEb+RwS1bPg4Xzt0fcJS9kVrFxa3ejhH7OW9vg= cloud.google.com/go/vmmigration v1.15.0 h1:F2uqT8+JXvSywV381YoQ4To3RnJETRtPkvcbdWXGmgM= cloud.google.com/go/vmmigration v1.15.0/go.mod h1:MP6mQ21ru1usBeCbl805Ioz0Fy+yf3qK2kUkhZ69QQY= +cloud.google.com/go/vmmigration v1.16.0 h1:OPGMxx73owRHMve5B4L6W+0KQUwlunyE9kfbYzFHqug= +cloud.google.com/go/vmmigration v1.16.0/go.mod h1:ILrSjXnHMpdamkkAU8fjMKKMsH27B6FLC5kv/6TkLy0= cloud.google.com/go/vmwareengine v1.3.5 h1:OsGd1SB91y9fDuzdzFngMv4UcT4cqmRxjsCsS4Xmcu8= cloud.google.com/go/vmwareengine v1.3.5/go.mod h1:QuVu2/b/eo8zcIkxBYY5QSwiyEcAy6dInI7N+keI+Jg= cloud.google.com/go/vmwareengine v1.3.6 h1:TKvULKbk44QrIx674cnoVjcZueXhyCAm2sNAJu/S1ds= cloud.google.com/go/vmwareengine v1.3.6/go.mod h1:ps0rb+Skgpt9ppHYC0o5DqtJ5ld2FyS8sAqtbHH8t9s= cloud.google.com/go/vmwareengine v1.8.0 h1:TmHKgTRH+mjq2VaaxrNcXqWyeleX7YaJPvrfWFCn0eE= cloud.google.com/go/vmwareengine v1.8.0/go.mod h1:e66l90IZhm1yQfYZv+YCWjSNSklQZCRmuEvKL8n3Ua0= +cloud.google.com/go/vmwareengine v1.9.0 h1:vdSutihjzH5iYEFlN+1x+gDm4xzj6Md2yMzXADKI5cQ= +cloud.google.com/go/vmwareengine v1.9.0/go.mod h1:zXXuUaIpvDhsV6sR+JdQfcQ4V5+pDarrp7FW7nOdS2I= cloud.google.com/go/vpcaccess v1.8.6 h1:RYtUB9rQEijX9Tc6lQcGst58ZOzPgaYTkz6+2pyPQTM= cloud.google.com/go/vpcaccess v1.8.6/go.mod h1:61yymNplV1hAbo8+kBOFO7Vs+4ZHYI244rSFgmsHC6E= cloud.google.com/go/vpcaccess v1.8.7 h1:K6siDR1T4HgSTv6sy6CAwupY7UGza6TQ1O8jtvEYoX4= cloud.google.com/go/vpcaccess v1.8.7/go.mod h1:9RYw5bVvk4Z51Rc8vwXT63yjEiMD/l7XyEaDyrNHgmk= cloud.google.com/go/vpcaccess v1.13.0 h1:aU7IKE/IAUgOzXCOgPsku4nV2DwmRpJHn6+QMf5Ub70= cloud.google.com/go/vpcaccess v1.13.0/go.mod h1:4Uus6E/9FYUtIrwBE1wJ1RosKwb02H6kEd9puJ02TL8= +cloud.google.com/go/vpcaccess v1.14.0 h1:fFa1i67AjC/3MuG5GPlvx36sIMeffpsIs3kI1C7S3Jk= +cloud.google.com/go/vpcaccess v1.14.0/go.mod h1:MxbVgr+2fpIFIEIdSmgnb8ykNWRPVtslpmWijp7an68= cloud.google.com/go/webrisk v1.11.1 h1:yZKNB7zRxOMriLrhP5WDE+BjxXVl0wJHHZSdaYzbdVU= cloud.google.com/go/webrisk v1.11.1/go.mod h1:+9SaepGg2lcp1p0pXuHyz3R2Yi2fHKKb4c1Q9y0qbtA= cloud.google.com/go/webrisk v1.11.2 h1:q6zEdVgD8Ka+4fQl3azDcSNRug8clNnQ9iVS2iLh+MM= cloud.google.com/go/webrisk v1.11.2/go.mod h1:yH44GeXz5iz4HFsIlGeoVvnjwnmfbni7Lwj1SelV4f0= cloud.google.com/go/webrisk v1.16.0 h1:OKkOJ81+YjGnrfN3oBNdpycZqKFNE4w52fSGo32rgNw= cloud.google.com/go/webrisk v1.16.0/go.mod h1:VIQw8smiaMOlget/xOk6niTkNJTiQc5skEmCuAksxJc= +cloud.google.com/go/webrisk v1.17.0 h1:t2WMlBo3xXxH6k5kTSkyIYUl24AHgmBI76F/5OqAPRY= +cloud.google.com/go/webrisk v1.17.0/go.mod h1:ypwCZ+G/SXyUZ+x3ppxn1hu+6tDifGNd/OpwPtCdJHI= cloud.google.com/go/websecurityscanner v1.7.6 h1:cIPKJKZA3l7D8DfL4nxce8HGOWXBw3WAUBF0ymOW9GQ= cloud.google.com/go/websecurityscanner v1.7.6/go.mod h1:ucaaTO5JESFn5f2pjdX01wGbQ8D6h79KHrmO2uGZeiY= cloud.google.com/go/websecurityscanner v1.7.7 h1:udhvvDDRryM3nrITJk/eQe74D06KK2N3SF60/FH2njQ= @@ -865,6 +1026,8 @@ cloud.google.com/go/workflows v1.14.3 h1:FGF6QEl3rtOSIHPOMZofWRVy3KNx26jDdgoYzJZ cloud.google.com/go/workflows v1.14.3/go.mod h1:CC9+YdVI2Kvp0L58WajHpEfKJxhrtRh3uQ0SYWcmAk4= cloud.google.com/go/workflows v1.19.0 h1:O5LlH7x1QovbDssany0TBe+hcSOcK5gPgIeaoByy0ZU= cloud.google.com/go/workflows v1.19.0/go.mod h1:TWsrDGgsJy7xAJ07byzHhKKehEWItJG3BivEHVhGH5g= +cloud.google.com/go/workflows v1.20.0 h1:qROpn1zRDdeIjUP9w9O3MHXJEg8v/pzVMicAcw9vLvQ= +cloud.google.com/go/workflows v1.20.0/go.mod h1:TC9yx7VpjGdBBeKM8FG2EMtms5Q9nyTqI+2uV9bDNs4= code.cloudfoundry.org/clock v1.2.0 h1:1swXS7yPmQmhAdkTb1nJ2c0geOdf4LvibUleNCo2HjA= code.cloudfoundry.org/clock v1.2.0/go.mod h1:foDbmVp5RIuIGlota90ot4FkJtx5m4+oKoWiVuu2FDg= codeberg.org/go-fonts/liberation v0.5.0 h1:SsKoMO1v1OZmzkG2DY+7ZkCL9U+rrWI09niOLfQ5Bo0= @@ -927,6 +1090,8 @@ github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapp github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.54.0/go.mod h1:Mf6O40IAyB9zR/1J8nGDDPirZQQPbYJni8Yisy7NTMc= github.com/IBM/sarama v1.43.1 h1:Z5uz65Px7f4DhI/jQqEm/tV9t8aU+JUdTyW/K/fCXpA= github.com/IBM/sarama v1.43.1/go.mod h1:GG5q1RURtDNPz8xxJs3mgX6Ytak8Z9eLhAkJPObe2xE= +github.com/IBM/sarama v1.50.3 h1:zpY2iZYmt+z+0Bo3aYF+cD48OBt2hIgiDPZUuZKTXcc= +github.com/IBM/sarama v1.50.3/go.mod h1:Jo4MSfdDT3ycmQj7/ab8eLZwnvwCKZm/8H7SCbtyo8U= github.com/Joker/jade v1.1.3 h1:Qbeh12Vq6BxURXT1qZBRHsDxeURB8ztcL6f3EXSGeHk= github.com/Joker/jade v1.1.3/go.mod h1:T+2WLyt7VH6Lp0TRxQrUYEs64nRc83wkMQrfeIQKduM= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= @@ -1027,6 +1192,8 @@ github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4 github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/eapache/go-resiliency v1.6.0 h1:CqGDTLtpwuWKn6Nj3uNUdflaq+/kIPsg0gfNzHton30= github.com/eapache/go-resiliency v1.6.0/go.mod h1:5yPzW0MIvSe0JDsv0v+DvcjEv2FyD6iZYSs1ZI+iQho= +github.com/eapache/go-resiliency v1.7.0 h1:n3NRTnBn5N0Cbi/IeOHuQn9s2UwVUH7Ga0ZWcP+9JTA= +github.com/eapache/go-resiliency v1.7.0/go.mod h1:5yPzW0MIvSe0JDsv0v+DvcjEv2FyD6iZYSs1ZI+iQho= github.com/eapache/go-xerial-snappy v0.0.0-20230731223053-c322873962e3 h1:Oy0F4ALJ04o5Qqpdz8XLIpNA3WM/iSIXqxtqo7UGVws= github.com/eapache/go-xerial-snappy v0.0.0-20230731223053-c322873962e3/go.mod h1:YvSRo5mw33fLEx1+DlK6L2VV43tJt5Eyel9n9XBcR+0= github.com/eapache/queue v1.1.0 h1:YOEu7KNc61ntiQlcEeUIoDTJ2o8mQznoNvUhiigpIqc= @@ -1195,6 +1362,8 @@ github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639 h1:mV02weK github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/ianlancetaylor/demangle v0.0.0-20240312041847-bd984b5ce465 h1:KwWnWVWCNtNq/ewIX7HIKnELmEx2nDP42yskD/pi7QE= github.com/ianlancetaylor/demangle v0.0.0-20240312041847-bd984b5ce465/go.mod h1:gx7rwoVhcfuVKG5uya9Hs3Sxj7EIvldVofAWIUtGouw= +github.com/ianlancetaylor/demangle v0.0.0-20250417193237-f615e6bd150b h1:ogbOPx86mIhFy764gGkqnkFC8m5PJA7sPzlk9ppLVQA= +github.com/ianlancetaylor/demangle v0.0.0-20250417193237-f615e6bd150b/go.mod h1:gx7rwoVhcfuVKG5uya9Hs3Sxj7EIvldVofAWIUtGouw= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/iris-contrib/schema v0.0.6 h1:CPSBLyx2e91H2yJzPuhGuifVRnZBBJ3pCOMbOvPZaTw= @@ -1290,6 +1459,8 @@ github.com/moby/moby/api v1.55.0 h1:2/sexvQyqIWS8pRSCFddBfpW2qE7vR7FCL+vN8pxwMc= github.com/moby/moby/api v1.55.0/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs= github.com/moby/moby/client v0.5.0 h1:5XhyPk2fuOWf6RlSFa3MkIIgDZkF25xToXW8Q/BH7cc= github.com/moby/moby/client v0.5.0/go.mod h1:rcVpF8ncl9vo5gaIBdol6CnbEtSj1uxMvEV/UrykF/s= +github.com/moby/moby/client v0.5.1 h1:tYNaJno4c0HXz12y5BiqEDy0rVTYkWzI26lGvnTMiJw= +github.com/moby/moby/client v0.5.1/go.mod h1:odLstlZ6uSnfvAgVxMpvgmb8SUdd+siH2T0GBuxVAlM= github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk= github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U= @@ -1349,6 +1520,8 @@ github.com/rabbitmq/amqp091-go v1.9.0 h1:qrQtyzB4H8BQgEuJwhmVQqVHB9O4+MNDJCCAcpc github.com/rabbitmq/amqp091-go v1.9.0/go.mod h1:+jPrT9iY2eLjRaMSRHUhc3z14E/l85kv/f+6luSD3pc= github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475 h1:N/ElC8H3+5XpJzTSTfLsJV/mx9Q9g7kxmchpfZyxgzM= github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= +github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9 h1:bsUq1dX0N8AOIL7EB/X911+m4EHsnWEHeJ0c+3TTBrg= +github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= github.com/rogpeppe/fastuuid v1.2.0 h1:Ppwyp6VYCF1nvBTXL3trRso7mXMlRrw9ooo375wvi2s= github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= @@ -1699,6 +1872,8 @@ golang.org/x/telemetry v0.0.0-20260508192327-42602be52be6 h1:HjU6IWBiAgRIdAJ9/y1 golang.org/x/telemetry v0.0.0-20260508192327-42602be52be6/go.mod h1:Eqhaxk/wZsWEH8CRxLwj6xzEJbz7k1EFGqx7nyCoabE= golang.org/x/telemetry v0.0.0-20260625142307-59b4966ccb57 h1:nwGZBCt+FnXUrGsj5vjzAsEmkcaFvd82BbOjECiFYZc= golang.org/x/telemetry v0.0.0-20260625142307-59b4966ccb57/go.mod h1:3AWMyWHS+caVoiEXpiq6+tzKA40J4vQT3MYr80ZtQpc= +golang.org/x/telemetry v0.0.0-20260708182218-49f421fb7959 h1:RJhm5l6Fo4rmEIcndxDllNhhf/fAx8qIm4t6A7vpm2A= +golang.org/x/telemetry v0.0.0-20260708182218-49f421fb7959/go.mod h1:LV7u5Oco+Z/g6XI7PqN+EUUUGGkEcmB1uj2ceI0fOVg= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= @@ -1979,6 +2154,7 @@ google.golang.org/genproto/googleapis/rpc v0.0.0-20260511170946-3700d4141b60/go. google.golang.org/genproto/googleapis/rpc v0.0.0-20260523011958-0a33c5d7ca68/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/genproto/googleapis/rpc v0.0.0-20260610212136-7ab31c22f7ad/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260720155508-bb71a54f79dc/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= diff --git a/pkg/gofr/datasource/clickhouse/go.mod b/pkg/gofr/datasource/clickhouse/go.mod index 53294ef7de..d51b18e209 100644 --- a/pkg/gofr/datasource/clickhouse/go.mod +++ b/pkg/gofr/datasource/clickhouse/go.mod @@ -3,7 +3,7 @@ module gofr.dev/pkg/gofr/datasource/clickhouse go 1.26.0 require ( - github.com/ClickHouse/clickhouse-go/v2 v2.47.0 + github.com/ClickHouse/clickhouse-go/v2 v2.48.0 github.com/stretchr/testify v1.11.1 go.opentelemetry.io/otel v1.44.0 go.opentelemetry.io/otel/trace v1.44.0 @@ -11,14 +11,14 @@ require ( ) require ( - github.com/ClickHouse/ch-go v0.73.0 // indirect - github.com/andybalholm/brotli v1.2.1 // indirect + github.com/ClickHouse/ch-go v0.74.0 // indirect + github.com/andybalholm/brotli v1.2.2 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/go-faster/city v1.0.1 // indirect github.com/go-faster/errors v0.7.1 // indirect github.com/google/uuid v1.6.0 // indirect - github.com/klauspost/compress v1.18.6 // indirect + github.com/klauspost/compress v1.19.1 // indirect github.com/kr/pretty v0.3.1 // indirect github.com/paulmach/orb v0.13.0 // indirect github.com/pierrec/lz4/v4 v4.1.27 // indirect @@ -26,8 +26,7 @@ require ( github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/segmentio/asm v1.2.1 // indirect github.com/shopspring/decimal v1.4.0 // indirect - go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/sys v0.46.0 // indirect + golang.org/x/sys v0.47.0 // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/pkg/gofr/datasource/clickhouse/go.sum b/pkg/gofr/datasource/clickhouse/go.sum index 628301d01d..344751d6a5 100644 --- a/pkg/gofr/datasource/clickhouse/go.sum +++ b/pkg/gofr/datasource/clickhouse/go.sum @@ -1,9 +1,9 @@ -github.com/ClickHouse/ch-go v0.73.0 h1:jsHiGRbQ3sz+gekvDFJF29LWDo5dzbJm5s1h8TWVP2M= -github.com/ClickHouse/ch-go v0.73.0/go.mod h1:wkFIxrqlXeRJ9cn3r5Fz5Qen9jl5aTMPuGZeuJpANNY= -github.com/ClickHouse/clickhouse-go/v2 v2.47.0 h1:ZDAzrnKSOPTIsm4tdUNfrii2yc8dk4SVRLC77BR7Z5Q= -github.com/ClickHouse/clickhouse-go/v2 v2.47.0/go.mod h1:sPj7C7UYQ2MWHcfX+4eGN6nwnCqwUKfgO6PcwKpd6K8= -github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro= -github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/ClickHouse/ch-go v0.74.0 h1:uYs2m4wIt0ZHSM1E72rg0maCfzhR2V3xWb/vZEgpeWE= +github.com/ClickHouse/ch-go v0.74.0/go.mod h1:sZ/r+8ttZMjyrP9PuFbgoVbth1ywIu2LIQNA2vgko6M= +github.com/ClickHouse/clickhouse-go/v2 v2.48.0 h1:auzd4VkapQYhQF8F2Gog7s3x78Bi1JZmByxGbrw3C+4= +github.com/ClickHouse/clickhouse-go/v2 v2.48.0/go.mod h1:lBjUCPRG6RpRQdMbkXq+JV8rY0/O5lw+Z7jShgReFjM= +github.com/andybalholm/brotli v1.2.2 h1:HzTuoo2ErYQqf5qvcJInB8uvqSVxRttzkFexPWtnceM= +github.com/andybalholm/brotli v1.2.2/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= @@ -17,8 +17,8 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao= -github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk= +github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= @@ -50,10 +50,8 @@ go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/ go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= -go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= -go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= -golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= diff --git a/pkg/gofr/datasource/cloudsql/go.mod b/pkg/gofr/datasource/cloudsql/go.mod index b020c4e402..25b7976b8f 100644 --- a/pkg/gofr/datasource/cloudsql/go.mod +++ b/pkg/gofr/datasource/cloudsql/go.mod @@ -3,48 +3,50 @@ module gofr.dev/pkg/gofr/datasource/cloudsql go 1.26.0 require ( - cloud.google.com/go/cloudsqlconn v1.22.1 + cloud.google.com/go/cloudsqlconn v1.25.1 github.com/go-sql-driver/mysql v1.10.0 github.com/jackc/pgx/v5 v5.10.0 github.com/stretchr/testify v1.11.1 ) require ( - cloud.google.com/go/auth v0.20.0 // indirect + cloud.google.com/go/auth v0.22.0 // indirect cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect cloud.google.com/go/compute/metadata v0.9.0 // indirect + cloud.google.com/go/sql v0.1.0 // indirect filippo.io/edwards25519 v1.2.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/felixge/httpsnoop v1.1.0 // indirect - github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/logr v1.4.4 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect github.com/google/s2a-go v0.1.9 // indirect github.com/google/uuid v1.6.0 // indirect - github.com/googleapis/enterprise-certificate-proxy v0.3.17 // indirect - github.com/googleapis/gax-go/v2 v2.22.0 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.18 // indirect + github.com/googleapis/gax-go/v2 v2.23.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect go.opencensus.io v0.24.0 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 // indirect go.opentelemetry.io/otel v1.44.0 // indirect go.opentelemetry.io/otel/metric v1.44.0 // indirect go.opentelemetry.io/otel/trace v1.44.0 // indirect - golang.org/x/crypto v0.53.0 // indirect - golang.org/x/net v0.56.0 // indirect + golang.org/x/crypto v0.54.0 // indirect + golang.org/x/net v0.57.0 // indirect golang.org/x/oauth2 v0.36.0 // indirect - golang.org/x/sync v0.21.0 // indirect - golang.org/x/sys v0.46.0 // indirect - golang.org/x/text v0.38.0 // indirect + golang.org/x/sync v0.22.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.40.0 // indirect golang.org/x/time v0.15.0 // indirect - google.golang.org/api v0.287.0 // indirect - google.golang.org/genproto v0.0.0-20260519071638-aa98bba5eb94 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20260615183401-62b3387ff324 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260622175928-b703f567277d // indirect + google.golang.org/api v0.290.0 // indirect + google.golang.org/genproto v0.0.0-20260723164925-7274b71286bd // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260723164925-7274b71286bd // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260723164925-7274b71286bd // indirect google.golang.org/grpc v1.83.0 // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/pkg/gofr/datasource/cloudsql/go.sum b/pkg/gofr/datasource/cloudsql/go.sum index 2adc355ac6..f8c17833f8 100644 --- a/pkg/gofr/datasource/cloudsql/go.sum +++ b/pkg/gofr/datasource/cloudsql/go.sum @@ -1,12 +1,14 @@ cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= -cloud.google.com/go/auth v0.20.0 h1:kXTssoVb4azsVDoUiF8KvxAqrsQcQtB53DcSgta74CA= -cloud.google.com/go/auth v0.20.0/go.mod h1:942/yi/itH1SsmpyrbnTMDgGfdy2BUqIKyd0cyYLc5Q= +cloud.google.com/go/auth v0.22.0 h1:Xp9wAKkLoeaYb5pYZZoQGz4E9sdPxIbzS3gywZE3ciQ= +cloud.google.com/go/auth v0.22.0/go.mod h1:M9o2Oz+YI2jAfxewJgb1vyI3vceHF+eohmxyzmrl+9s= cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= -cloud.google.com/go/cloudsqlconn v1.22.1 h1:c4HkWMSV4pDL8kJid+nIuF+6iE07pR5Mn/sRVY17wf4= -cloud.google.com/go/cloudsqlconn v1.22.1/go.mod h1:p7l+u0ThOzSvC5a4fkywi1hEyD8S709X1zEqux9tsq0= +cloud.google.com/go/cloudsqlconn v1.25.1 h1:NSm9zyFySRjFivbOSGi0Mv5VS59SNel7OnWkkMmKUa8= +cloud.google.com/go/cloudsqlconn v1.25.1/go.mod h1:yBjHpKuIGsmVTrqgMqfAvs1o3V0f8ee+wCiCkM61UjI= cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= +cloud.google.com/go/sql v0.1.0 h1:WNRz/Xe/jeR7ChgaQ8vahbX7zAF/mRPZshvVlMtkbws= +cloud.google.com/go/sql v0.1.0/go.mod h1:LZWBMAQhN4oBgqz3GRcpNTom8+U2v97D7d5qLiZmZlg= filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= @@ -15,6 +17,8 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UF github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= +github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 h1:aBangftG7EVZoUb69Os8IaYg++6uMOdKK83QtkkvJik= +github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4= 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= @@ -22,12 +26,17 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8Yc github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= +github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA= +github.com/envoyproxy/go-control-plane/envoy v1.37.0 h1:u3riX6BoYRfF4Dr7dwSOroNfdSbEPe9Yyl09/B6wBrQ= +github.com/envoyproxy/go-control-plane/envoy v1.37.0/go.mod h1:DReE9MMrmecPy+YvQOAOHNYMALuowAnbjjEMkkWOi6A= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +github.com/envoyproxy/protoc-gen-validate v1.3.3 h1:MVQghNeW+LZcmXe7SY1V36Z+WFMDjpqGAGacLe2T0ds= +github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0= github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc= github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= -github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= -github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= +github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-sql-driver/mysql v1.10.0 h1:Q+1LV8DkHJvSYAdR83XzuhDaTykuDx0l6fkXxoWCWfw= @@ -65,10 +74,10 @@ github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0 github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/googleapis/enterprise-certificate-proxy v0.3.17 h1:73NfMHdiqo9JFU9+7a5ExpVa10/R29pXfZIaW559nrg= -github.com/googleapis/enterprise-certificate-proxy v0.3.17/go.mod h1:rSEsBUemEBZEexP2y6jPp16LUmUbjmSbcPMQizR0o4k= -github.com/googleapis/gax-go/v2 v2.22.0 h1:PjIWBpgGIVKGoCXuiCoP64altEJCj3/Ei+kSU5vlZD4= -github.com/googleapis/gax-go/v2 v2.22.0/go.mod h1:irWBbALSr0Sk3qlqb9SyJ1h68WjgeFuiOzI4Rqw5+aY= +github.com/googleapis/enterprise-certificate-proxy v0.3.18 h1:hvVi34VucdrV1IIsiWuqYM8kutw/92MxNEFxCJZEh0k= +github.com/googleapis/enterprise-certificate-proxy v0.3.18/go.mod h1:rSEsBUemEBZEexP2y6jPp16LUmUbjmSbcPMQizR0o4k= +github.com/googleapis/gax-go/v2 v2.23.0 h1:Tchl7qkvE7Ip3y+ztvNufYFvkfqTe7NfLTYGIdJRLuE= +github.com/googleapis/gax-go/v2 v2.23.0/go.mod h1:rBQKOVJCdb8IFEzg+FCwlt1LP/xMDGuqUXhUG+XMXEg= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= @@ -83,6 +92,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/microsoft/go-mssqldb v1.10.0 h1:pHEt+Qz6YFPWqREq10mqSE524QQo+/QremwTCQht7TY= github.com/microsoft/go-mssqldb v1.10.0/go.mod h1:mnG7lGa9iYJbzJqGCXyuQCegStKMr3kogDLD6+bmggg= +github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo= +github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -105,6 +116,8 @@ go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 h1:yI1/OhfEPy7J9eoa6Sj051C7n5dvpj0QX8g4sRchg04= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0/go.mod h1:NoUCKYWK+3ecatC4HjkRktREheMeEtrXoQxrqYFeHSc= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 h1:8tvICD4vSTOOsNrsI4Ljf6C+6UKvpTEH5XY3JMoyPoo= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0/go.mod h1:z9+yiacE0IHRqM4qFfkbt/JYlmYXgss8GY/jXoNuPJI= go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= @@ -119,8 +132,8 @@ go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/ go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= -golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= +golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= +golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= @@ -131,26 +144,26 @@ golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73r golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= -golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= +golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= +golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= -golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= -golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= -golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -161,19 +174,19 @@ golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBn golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= -google.golang.org/api v0.287.0 h1:CQDMqUiqZZ0U/Yge3zyjAhNQ0OSYEH0PaA7l4xtEen4= -google.golang.org/api v0.287.0/go.mod h1:pPW85yt3Iuc3unkpaMhFtMmOqnTdCwCqEOaUlnuxRlQ= +google.golang.org/api v0.290.0 h1:eMw0Xo+IfbbMlKmW7aHvpyQRv9RCXuWx/vs8AD+0x9A= +google.golang.org/api v0.290.0/go.mod h1:weJZ3lldHFYI0DBFNKpJelUDNnusTt5YaOEgxvt8ci8= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= -google.golang.org/genproto v0.0.0-20260519071638-aa98bba5eb94 h1:YJjbgu+dkp5kUJLfpMyCLfBIWZb/FcJyuLeo1gVBOuo= -google.golang.org/genproto v0.0.0-20260519071638-aa98bba5eb94/go.mod h1:RRHjglSYABVCWpQ7USCpdfhcd9t4PkajvVwyynZizTc= -google.golang.org/genproto/googleapis/api v0.0.0-20260615183401-62b3387ff324 h1:g0RAkxK/smSu/iRwC/KIX1mwUoVJtk2OjbgaeS4DmUM= -google.golang.org/genproto/googleapis/api v0.0.0-20260615183401-62b3387ff324/go.mod h1:Z4WJ5pJOYWFWcHEQUelD5QaZDknIQkpIL/+fyJOT9+A= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260622175928-b703f567277d h1:mpAgMyM9vQHxycBlDq50y1VHpfSfVwzXvrQKtYbXuUY= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260622175928-b703f567277d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/genproto v0.0.0-20260723164925-7274b71286bd h1:0GnSESHqea5EoaQgJyYxC3o5m0L191gCQdDgo2cxO5I= +google.golang.org/genproto v0.0.0-20260723164925-7274b71286bd/go.mod h1:Wz2wFJntZFmLGo7pLDXZ3wYk5hyc0Mb+SkHhDDXT+lU= +google.golang.org/genproto/googleapis/api v0.0.0-20260723164925-7274b71286bd h1:k+Z6yS8OmX4IJpSXEjeT0nqv6efIFFaa5DfDVeqy16A= +google.golang.org/genproto/googleapis/api v0.0.0-20260723164925-7274b71286bd/go.mod h1:1brfde68Npq6+WA75c1EHWPijZEG1kMus61ygPZfn4A= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260723164925-7274b71286bd h1:kPm/AOyXSYAcNdY53xxeI0SJa5xuS+Z5op/stkZMTcA= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260723164925-7274b71286bd/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= diff --git a/pkg/gofr/datasource/file/s3/go.mod b/pkg/gofr/datasource/file/s3/go.mod index 5b4f42e0d6..fd059fc432 100644 --- a/pkg/gofr/datasource/file/s3/go.mod +++ b/pkg/gofr/datasource/file/s3/go.mod @@ -3,30 +3,30 @@ module gofr.dev/pkg/gofr/datasource/file/s3 go 1.26.0 require ( - github.com/aws/aws-sdk-go-v2 v1.43.3 - github.com/aws/aws-sdk-go-v2/config v1.32.34 - github.com/aws/aws-sdk-go-v2/credentials v1.19.33 - github.com/aws/aws-sdk-go-v2/service/s3 v1.105.2 + github.com/aws/aws-sdk-go-v2 v1.43.6 + github.com/aws/aws-sdk-go-v2/config v1.32.37 + github.com/aws/aws-sdk-go-v2/credentials v1.19.36 + github.com/aws/aws-sdk-go-v2/service/s3 v1.107.2 github.com/stretchr/testify v1.11.1 go.uber.org/mock v0.6.0 gofr.dev v1.57.0 ) require ( - github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.14 // indirect - github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.34 // indirect - github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.34 // indirect - github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.34 // indirect - github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.35 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.15 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.23 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.34 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.31 // indirect - github.com/aws/aws-sdk-go-v2/service/signin v1.5.3 // indirect - github.com/aws/aws-sdk-go-v2/service/sso v1.33.3 // indirect - github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.3 // indirect - github.com/aws/aws-sdk-go-v2/service/sts v1.45.3 // indirect - github.com/aws/smithy-go v1.27.6 // indirect + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.18 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.37 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.37 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.37 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.38 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.17 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.30 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.37 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.38 // indirect + github.com/aws/aws-sdk-go-v2/service/signin v1.5.6 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.33.6 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.6 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.45.6 // indirect + github.com/aws/smithy-go v1.27.8 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/joho/godotenv v1.5.1 // indirect github.com/kr/pretty v0.3.1 // indirect diff --git a/pkg/gofr/datasource/file/s3/go.sum b/pkg/gofr/datasource/file/s3/go.sum index 17d96a4ee8..1596698d99 100644 --- a/pkg/gofr/datasource/file/s3/go.sum +++ b/pkg/gofr/datasource/file/s3/go.sum @@ -1,39 +1,39 @@ -github.com/aws/aws-sdk-go-v2 v1.43.3 h1:XJIcfv8uDs2ukdQsoAC8/Ebu1ejxwzlayl2ZsiFns2A= -github.com/aws/aws-sdk-go-v2 v1.43.3/go.mod h1:70vwSy16txshwG+g55WkpgPKDIByzHI8ccBsOteo3bQ= -github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.14 h1:3IZY0XAJquT3aHzbkHfPzy4ACPcEjVG0x87KOwtpqGY= -github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.14/go.mod h1:zwM6veDkhGgQFqkBy+uT28AAYpLu+uFMlPl+rCg/73E= -github.com/aws/aws-sdk-go-v2/config v1.32.34 h1:o+YAizrX562nEZXaB38uYTK8RvIsvW0uuRP+e5e0Pfk= -github.com/aws/aws-sdk-go-v2/config v1.32.34/go.mod h1:wc0zYRChOniiufvdWiRVf3jgXSgbkvaD683IHHHc2ZQ= -github.com/aws/aws-sdk-go-v2/credentials v1.19.33 h1:/e5V3EWfeDiW6cuRxHsC8gbwko4/vvVYPJR2afBKFFY= -github.com/aws/aws-sdk-go-v2/credentials v1.19.33/go.mod h1:ZxAmkcyOM9beY/WO9oxp2oVPXiP3rq5N1/p4NbenJdE= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.34 h1:1EsGke6rTD2CG3j2MMVB77n6Q+FlbQWYI/dFdLWBNtM= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.34/go.mod h1:5B1Z/QbaWzqoWRzYxZfmCbDDRcvUHcfAIQw/S+KfDmc= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.34 h1:vuIfjzoeqhQMGJyOBU3t0ZEjn2jrN8Bbg1N4CgjzM5Q= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.34/go.mod h1:hP28cN4CPJLZHirdQPrZR50JcLN4ApRJP2tzG8cRlhY= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.34 h1:9faHsnqxJ1vDvB4wMZy/ajIDyz5QhllQjjc72RJpXAw= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.34/go.mod h1:Yp6nIyejpa23nzlB/LhT63KTla9Jdi06nv/HH/OkAH8= -github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.35 h1:Oe8gMKJLO5awqpa5EhAGKVnBv1s+brdWVuxM2mDa7zA= -github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.35/go.mod h1:FZevcG9cOST/FWAAUhHIchjR9fXFXFRCWodOhx+PDLA= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.15 h1:JJLBQxwY+AFwuPAi5ivGc1ChnTdUt4cXMv7e76m2c/Y= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.15/go.mod h1:lQknBIe78MVL0cQOQDlag8KGflMbMEVFx9mB6O8ENvk= -github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.23 h1:9Fjh6fi/U5JEStVZijmaMpUwE/gvBJj7x2B/PjbO9To= -github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.23/go.mod h1:iMoT2f1tClxrWAAnKCXjZQ6LOmfLrMG14wmnWpM+F14= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.34 h1:sYg4qHWLqsjp15PzX7XCOHSOgKEGoZ5vQY43VvZ1pas= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.34/go.mod h1:N58SSz3roKf1HzW5qRaOiyk6MbDLTKgLPvlTfJ90iyI= -github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.31 h1:uao4A3QZ5UmB326V6KF+qRpv9Tjz7IlnlnTbbANntlU= -github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.31/go.mod h1:I/1+z0VwL1GhQyLgkoHDlygpUZ+iTAwOQ/NsftiUL2I= -github.com/aws/aws-sdk-go-v2/service/s3 v1.105.2 h1:5C00eQYpTrgQXnp6V3P6P7zPElna3AXvlukbANE6nJI= -github.com/aws/aws-sdk-go-v2/service/s3 v1.105.2/go.mod h1:zdmCoFO/dSI7GlrwsPqFJI+WlFnSU4Tc8TJnlXrM1Do= -github.com/aws/aws-sdk-go-v2/service/signin v1.5.3 h1:togAtAmgV5IGMnQDuBDJeM8z5Y5RN6G7xeOgphWz+Yc= -github.com/aws/aws-sdk-go-v2/service/signin v1.5.3/go.mod h1:T7xKUUUvN7W3RW8UmMvKnD12xqh+Ux2gCPHPhnt64Dg= -github.com/aws/aws-sdk-go-v2/service/sso v1.33.3 h1:YjH64OUytnWZBHUtM9GMyi4ZWBiSQdEJkZuPykOIe44= -github.com/aws/aws-sdk-go-v2/service/sso v1.33.3/go.mod h1:5qoHcDZDTSJotoKk1bvVRPv1MXaL/NhfY9ng8D1g/ig= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.3 h1:A4o1di/XGaqtw6r3toSBrFX2U7mVSLqg7jo9wL4I+cU= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.3/go.mod h1:sKuKz2kHtrGVtFu34vbM3LWSA9CKD9YZUmm6e5PPqRA= -github.com/aws/aws-sdk-go-v2/service/sts v1.45.3 h1:Fi7+DiKN1+QphlajvE6FqeZ8GRbnnRul7zTdUiRpbGc= -github.com/aws/aws-sdk-go-v2/service/sts v1.45.3/go.mod h1:KCc3e27fHZUGtzpek7wZcp6dyCpGkJJo/+3PBujh/yU= -github.com/aws/smithy-go v1.27.6 h1:0zjT8jgK3jbrTT7JJ3EE6JsMhX8JTrZ+f1sEndYDXrA= -github.com/aws/smithy-go v1.27.6/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= +github.com/aws/aws-sdk-go-v2 v1.43.6 h1:RrmFcqCBxkJuf7g1axVo5krB4jM/AO8r5e5oujrgdoQ= +github.com/aws/aws-sdk-go-v2 v1.43.6/go.mod h1:tXpPM+v0D1lndmga+HqqLDIzUFJlEeR21aspVklHF00= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.18 h1:LAfOuhAH331fmOjTQpAaOlH+Ftn7RzSDJ2VFwjdMMy4= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.18/go.mod h1:4e5xhuXHx1e4U9EthvbPP1r/DIMp5c2823OL8karzcM= +github.com/aws/aws-sdk-go-v2/config v1.32.37 h1:Ljl7LOJB6ym0liuEl0+TZ3d7f5I8MEZN1Cj9PINlj/g= +github.com/aws/aws-sdk-go-v2/config v1.32.37/go.mod h1:WJ7pe7ZPpmG8Q5kKS53zeypIV4FBGACxmte8Uc6SgUc= +github.com/aws/aws-sdk-go-v2/credentials v1.19.36 h1:84s5xMme6ENYEdKG8rsbSFFg/8+lbHBeM9QYSO0gnDk= +github.com/aws/aws-sdk-go-v2/credentials v1.19.36/go.mod h1:c46BLdagDLIswjgt+GeQOslXgeS0E6wCacs5yZbxPGk= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.37 h1:b5tb+CZItBkydC7r3hTNdSO3pszG1R2EtnA+7TePQPk= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.37/go.mod h1:ZQ+6SU9X0oz6+7MUCSswv9Mjci4eaqZr21HI2RVy/yA= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.37 h1:lznzIOvvbqjfe8UAaciCRJgBgJsxuTROKlhZuXQWfv8= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.37/go.mod h1:otfkzyfQeMMLZAqX59GSXTL3o22BR/l6HFaRzzbWSqA= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.37 h1:zCEORWo0eU0gDjG+IyApE/2B+ZGG1m+GU7B263XV8ds= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.37/go.mod h1:i6c0PEl3TNOWxRbQ++KQcVenPWS/GoQeiklKhNuqzJ8= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.38 h1:A3UAuCmx7LyUcrixBTzKJYYIUZ2yTvn6ZhT8PB+7APk= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.38/go.mod h1:1PDUYG9Z+JrbbsobsAZHjWOm9QBT/djiK3QbykTL5Z4= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.17 h1:OvYZOB3qA6zvfdRFiRFRzVSiElMYrz3GdntkXZxlp1o= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.17/go.mod h1:JgR/2Ew50ACfIWau1oeMRX59tMtC0kM+PYQGEaT04cY= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.30 h1:5437eMoOwqqQpZn2XJy74mlDCuPYL81texMT3mXqgtU= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.30/go.mod h1:xfu2m3dOpvW8lj98wQYa8V9ku/Rta59hsbireGzhh3A= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.37 h1:a3D4AjrOrTrP8+d9ILBthqrElf0z1JNol09Xvnwcys8= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.37/go.mod h1:ky0gTu+ukvUTuUKFIpp6Wid4oninrkCyvbFkVs0kpHM= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.38 h1:gX8B8y3Ho30B1LPxefDKMi/HZqWEb47U9ogs3DtSG0M= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.38/go.mod h1:l5WblZlcmGPe4/O7JY2HO25Z+xqTBvyfTyFbRMf8gYw= +github.com/aws/aws-sdk-go-v2/service/s3 v1.107.2 h1:GNU0/xtPEXMKilJZ/a8BedeuQnvu+Usi6qVm9EFfncc= +github.com/aws/aws-sdk-go-v2/service/s3 v1.107.2/go.mod h1:4jYWUecEsQtE73jPl7p3jrbYXH5ffcR4gegyCygagfg= +github.com/aws/aws-sdk-go-v2/service/signin v1.5.6 h1:i68sFvXidKlkiSvI7d7Ilc1/UvW4CtBOaivH7jhG4fs= +github.com/aws/aws-sdk-go-v2/service/signin v1.5.6/go.mod h1:/h7Obr9WTtzbjTHGASRQwLN7Bupw+TC3x8x7fyx39hE= +github.com/aws/aws-sdk-go-v2/service/sso v1.33.6 h1:tpfGChmjUmv3W9WlRvy+stwKDTbFFdq8Zk9DbFPrfMU= +github.com/aws/aws-sdk-go-v2/service/sso v1.33.6/go.mod h1:CSjiDzmG/lsKkTOYjbkM+duLmRlW+LOxD64Na44ijnI= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.6 h1:49BBtY68A+KJCQ3a2F3eUe6ROsKucxUdfHKoqorc0wI= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.6/go.mod h1:ptG2hbs7QltE1GcQY0MpS4bfrc51KCnBXUr7OT1EEfE= +github.com/aws/aws-sdk-go-v2/service/sts v1.45.6 h1:JvExZWabChDM0qJAirQYGfOYo0ndT3edXj+fqSPNjkE= +github.com/aws/aws-sdk-go-v2/service/sts v1.45.6/go.mod h1:XZcaQkV2cItp6yEkrwljyaPOf22RuX7T43jxap/FOmM= +github.com/aws/smithy-go v1.27.8 h1:FR0dxZfIlV7Z8eh2iHfIofdunw382XsDV3Mxt9nUvRY= +github.com/aws/smithy-go v1.27.8/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= diff --git a/pkg/gofr/datasource/file/sftp/go.mod b/pkg/gofr/datasource/file/sftp/go.mod index 1e04d81617..b31c7ddb32 100644 --- a/pkg/gofr/datasource/file/sftp/go.mod +++ b/pkg/gofr/datasource/file/sftp/go.mod @@ -7,7 +7,7 @@ require ( github.com/stretchr/testify v1.11.1 go.uber.org/mock v0.6.0 gofr.dev v1.57.0 - golang.org/x/crypto v0.54.0 + golang.org/x/crypto v0.55.0 ) require ( diff --git a/pkg/gofr/datasource/file/sftp/go.sum b/pkg/gofr/datasource/file/sftp/go.sum index d232fe773e..b0aa18de43 100644 --- a/pkg/gofr/datasource/file/sftp/go.sum +++ b/pkg/gofr/datasource/file/sftp/go.sum @@ -34,8 +34,8 @@ go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= gofr.dev v1.57.0 h1:4rsJ6VI3Yk7febCFtszhpKU46H8vJWTLmeICwGgZh64= gofr.dev v1.57.0/go.mod h1:bnPTbwKQbUZAMb4bZNWOjNE+LZJP1WdayrZuSakTUXk= -golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= -golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= +golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= +golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= diff --git a/pkg/gofr/datasource/kv-store/dynamodb/go.mod b/pkg/gofr/datasource/kv-store/dynamodb/go.mod index 50f1a6545e..adae56f1da 100644 --- a/pkg/gofr/datasource/kv-store/dynamodb/go.mod +++ b/pkg/gofr/datasource/kv-store/dynamodb/go.mod @@ -3,8 +3,8 @@ module gofr.dev/pkg/gofr/datasource/kv-store/dynamodb go 1.26.0 require ( - github.com/aws/aws-sdk-go-v2 v1.43.3 - github.com/aws/aws-sdk-go-v2/config v1.32.34 + github.com/aws/aws-sdk-go-v2 v1.43.6 + github.com/aws/aws-sdk-go-v2/config v1.32.37 github.com/aws/aws-sdk-go-v2/service/dynamodb v1.60.1 github.com/stretchr/testify v1.11.1 go.opentelemetry.io/otel v1.44.0 @@ -13,19 +13,19 @@ require ( ) require ( - github.com/aws/aws-sdk-go-v2/credentials v1.19.33 // indirect - github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.34 // indirect - github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.34 // indirect - github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.34 // indirect - github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.35 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.15 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.19.36 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.37 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.37 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.37 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.38 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.17 // indirect github.com/aws/aws-sdk-go-v2/service/internal/endpoint-discovery v1.12.7 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.34 // indirect - github.com/aws/aws-sdk-go-v2/service/signin v1.5.3 // indirect - github.com/aws/aws-sdk-go-v2/service/sso v1.33.3 // indirect - github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.3 // indirect - github.com/aws/aws-sdk-go-v2/service/sts v1.45.3 // indirect - github.com/aws/smithy-go v1.27.6 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.37 // indirect + github.com/aws/aws-sdk-go-v2/service/signin v1.5.6 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.33.6 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.6 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.45.6 // indirect + github.com/aws/smithy-go v1.27.8 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/kr/pretty v0.3.1 // indirect diff --git a/pkg/gofr/datasource/kv-store/dynamodb/go.sum b/pkg/gofr/datasource/kv-store/dynamodb/go.sum index 6b67e770dd..a1188342ad 100644 --- a/pkg/gofr/datasource/kv-store/dynamodb/go.sum +++ b/pkg/gofr/datasource/kv-store/dynamodb/go.sum @@ -1,35 +1,35 @@ -github.com/aws/aws-sdk-go-v2 v1.43.3 h1:XJIcfv8uDs2ukdQsoAC8/Ebu1ejxwzlayl2ZsiFns2A= -github.com/aws/aws-sdk-go-v2 v1.43.3/go.mod h1:70vwSy16txshwG+g55WkpgPKDIByzHI8ccBsOteo3bQ= -github.com/aws/aws-sdk-go-v2/config v1.32.34 h1:o+YAizrX562nEZXaB38uYTK8RvIsvW0uuRP+e5e0Pfk= -github.com/aws/aws-sdk-go-v2/config v1.32.34/go.mod h1:wc0zYRChOniiufvdWiRVf3jgXSgbkvaD683IHHHc2ZQ= -github.com/aws/aws-sdk-go-v2/credentials v1.19.33 h1:/e5V3EWfeDiW6cuRxHsC8gbwko4/vvVYPJR2afBKFFY= -github.com/aws/aws-sdk-go-v2/credentials v1.19.33/go.mod h1:ZxAmkcyOM9beY/WO9oxp2oVPXiP3rq5N1/p4NbenJdE= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.34 h1:1EsGke6rTD2CG3j2MMVB77n6Q+FlbQWYI/dFdLWBNtM= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.34/go.mod h1:5B1Z/QbaWzqoWRzYxZfmCbDDRcvUHcfAIQw/S+KfDmc= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.34 h1:vuIfjzoeqhQMGJyOBU3t0ZEjn2jrN8Bbg1N4CgjzM5Q= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.34/go.mod h1:hP28cN4CPJLZHirdQPrZR50JcLN4ApRJP2tzG8cRlhY= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.34 h1:9faHsnqxJ1vDvB4wMZy/ajIDyz5QhllQjjc72RJpXAw= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.34/go.mod h1:Yp6nIyejpa23nzlB/LhT63KTla9Jdi06nv/HH/OkAH8= -github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.35 h1:Oe8gMKJLO5awqpa5EhAGKVnBv1s+brdWVuxM2mDa7zA= -github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.35/go.mod h1:FZevcG9cOST/FWAAUhHIchjR9fXFXFRCWodOhx+PDLA= +github.com/aws/aws-sdk-go-v2 v1.43.6 h1:RrmFcqCBxkJuf7g1axVo5krB4jM/AO8r5e5oujrgdoQ= +github.com/aws/aws-sdk-go-v2 v1.43.6/go.mod h1:tXpPM+v0D1lndmga+HqqLDIzUFJlEeR21aspVklHF00= +github.com/aws/aws-sdk-go-v2/config v1.32.37 h1:Ljl7LOJB6ym0liuEl0+TZ3d7f5I8MEZN1Cj9PINlj/g= +github.com/aws/aws-sdk-go-v2/config v1.32.37/go.mod h1:WJ7pe7ZPpmG8Q5kKS53zeypIV4FBGACxmte8Uc6SgUc= +github.com/aws/aws-sdk-go-v2/credentials v1.19.36 h1:84s5xMme6ENYEdKG8rsbSFFg/8+lbHBeM9QYSO0gnDk= +github.com/aws/aws-sdk-go-v2/credentials v1.19.36/go.mod h1:c46BLdagDLIswjgt+GeQOslXgeS0E6wCacs5yZbxPGk= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.37 h1:b5tb+CZItBkydC7r3hTNdSO3pszG1R2EtnA+7TePQPk= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.37/go.mod h1:ZQ+6SU9X0oz6+7MUCSswv9Mjci4eaqZr21HI2RVy/yA= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.37 h1:lznzIOvvbqjfe8UAaciCRJgBgJsxuTROKlhZuXQWfv8= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.37/go.mod h1:otfkzyfQeMMLZAqX59GSXTL3o22BR/l6HFaRzzbWSqA= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.37 h1:zCEORWo0eU0gDjG+IyApE/2B+ZGG1m+GU7B263XV8ds= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.37/go.mod h1:i6c0PEl3TNOWxRbQ++KQcVenPWS/GoQeiklKhNuqzJ8= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.38 h1:A3UAuCmx7LyUcrixBTzKJYYIUZ2yTvn6ZhT8PB+7APk= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.38/go.mod h1:1PDUYG9Z+JrbbsobsAZHjWOm9QBT/djiK3QbykTL5Z4= github.com/aws/aws-sdk-go-v2/service/dynamodb v1.60.1 h1:JX6naxruLi55bTc6XGz7t/FK6zBAF/on9P1eBvSdo44= github.com/aws/aws-sdk-go-v2/service/dynamodb v1.60.1/go.mod h1:HnWoC3m6VmjUSg+kBL6OgQsXdyRAGzBYWb7B3J2f+JM= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.15 h1:JJLBQxwY+AFwuPAi5ivGc1ChnTdUt4cXMv7e76m2c/Y= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.15/go.mod h1:lQknBIe78MVL0cQOQDlag8KGflMbMEVFx9mB6O8ENvk= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.17 h1:OvYZOB3qA6zvfdRFiRFRzVSiElMYrz3GdntkXZxlp1o= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.17/go.mod h1:JgR/2Ew50ACfIWau1oeMRX59tMtC0kM+PYQGEaT04cY= github.com/aws/aws-sdk-go-v2/service/internal/endpoint-discovery v1.12.7 h1:uqsKxr7kJp9DXVj2m8KbVeZcYMuwsNEwvoVrYl2Vpf8= github.com/aws/aws-sdk-go-v2/service/internal/endpoint-discovery v1.12.7/go.mod h1:Js/P8Zbwe1mRejnD+OpFLyQiJ8ioQlo3GMAg7Dfxk7w= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.34 h1:sYg4qHWLqsjp15PzX7XCOHSOgKEGoZ5vQY43VvZ1pas= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.34/go.mod h1:N58SSz3roKf1HzW5qRaOiyk6MbDLTKgLPvlTfJ90iyI= -github.com/aws/aws-sdk-go-v2/service/signin v1.5.3 h1:togAtAmgV5IGMnQDuBDJeM8z5Y5RN6G7xeOgphWz+Yc= -github.com/aws/aws-sdk-go-v2/service/signin v1.5.3/go.mod h1:T7xKUUUvN7W3RW8UmMvKnD12xqh+Ux2gCPHPhnt64Dg= -github.com/aws/aws-sdk-go-v2/service/sso v1.33.3 h1:YjH64OUytnWZBHUtM9GMyi4ZWBiSQdEJkZuPykOIe44= -github.com/aws/aws-sdk-go-v2/service/sso v1.33.3/go.mod h1:5qoHcDZDTSJotoKk1bvVRPv1MXaL/NhfY9ng8D1g/ig= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.3 h1:A4o1di/XGaqtw6r3toSBrFX2U7mVSLqg7jo9wL4I+cU= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.3/go.mod h1:sKuKz2kHtrGVtFu34vbM3LWSA9CKD9YZUmm6e5PPqRA= -github.com/aws/aws-sdk-go-v2/service/sts v1.45.3 h1:Fi7+DiKN1+QphlajvE6FqeZ8GRbnnRul7zTdUiRpbGc= -github.com/aws/aws-sdk-go-v2/service/sts v1.45.3/go.mod h1:KCc3e27fHZUGtzpek7wZcp6dyCpGkJJo/+3PBujh/yU= -github.com/aws/smithy-go v1.27.6 h1:0zjT8jgK3jbrTT7JJ3EE6JsMhX8JTrZ+f1sEndYDXrA= -github.com/aws/smithy-go v1.27.6/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.37 h1:a3D4AjrOrTrP8+d9ILBthqrElf0z1JNol09Xvnwcys8= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.37/go.mod h1:ky0gTu+ukvUTuUKFIpp6Wid4oninrkCyvbFkVs0kpHM= +github.com/aws/aws-sdk-go-v2/service/signin v1.5.6 h1:i68sFvXidKlkiSvI7d7Ilc1/UvW4CtBOaivH7jhG4fs= +github.com/aws/aws-sdk-go-v2/service/signin v1.5.6/go.mod h1:/h7Obr9WTtzbjTHGASRQwLN7Bupw+TC3x8x7fyx39hE= +github.com/aws/aws-sdk-go-v2/service/sso v1.33.6 h1:tpfGChmjUmv3W9WlRvy+stwKDTbFFdq8Zk9DbFPrfMU= +github.com/aws/aws-sdk-go-v2/service/sso v1.33.6/go.mod h1:CSjiDzmG/lsKkTOYjbkM+duLmRlW+LOxD64Na44ijnI= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.6 h1:49BBtY68A+KJCQ3a2F3eUe6ROsKucxUdfHKoqorc0wI= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.6/go.mod h1:ptG2hbs7QltE1GcQY0MpS4bfrc51KCnBXUr7OT1EEfE= +github.com/aws/aws-sdk-go-v2/service/sts v1.45.6 h1:JvExZWabChDM0qJAirQYGfOYo0ndT3edXj+fqSPNjkE= +github.com/aws/aws-sdk-go-v2/service/sts v1.45.6/go.mod h1:XZcaQkV2cItp6yEkrwljyaPOf22RuX7T43jxap/FOmM= +github.com/aws/smithy-go v1.27.8 h1:FR0dxZfIlV7Z8eh2iHfIofdunw382XsDV3Mxt9nUvRY= +github.com/aws/smithy-go v1.27.8/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= diff --git a/pkg/gofr/datasource/pubsub/nats/go.mod b/pkg/gofr/datasource/pubsub/nats/go.mod index fcd04e7de3..5229895be8 100644 --- a/pkg/gofr/datasource/pubsub/nats/go.mod +++ b/pkg/gofr/datasource/pubsub/nats/go.mod @@ -3,7 +3,7 @@ module gofr.dev/pkg/gofr/datasource/pubsub/nats go 1.26.0 require ( - github.com/nats-io/nats-server/v2 v2.14.4 + github.com/nats-io/nats-server/v2 v2.14.5 github.com/nats-io/nats.go v1.52.0 github.com/stretchr/testify v1.11.1 go.opentelemetry.io/otel v1.44.0 @@ -22,7 +22,7 @@ require ( github.com/google/go-tpm v0.9.8 // indirect github.com/google/uuid v1.6.0 // indirect github.com/joho/godotenv v1.5.1 // indirect - github.com/klauspost/compress v1.19.0 // indirect + github.com/klauspost/compress v1.19.2 // indirect github.com/minio/highwayhash v1.0.4 // indirect github.com/nats-io/jwt/v2 v2.8.2 // indirect github.com/nats-io/nkeys v0.4.16 // indirect @@ -31,7 +31,7 @@ require ( github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/otel/metric v1.44.0 // indirect - golang.org/x/crypto v0.54.0 // indirect + golang.org/x/crypto v0.55.0 // indirect golang.org/x/sys v0.47.0 // indirect golang.org/x/term v0.45.0 // indirect golang.org/x/time v0.15.0 // indirect diff --git a/pkg/gofr/datasource/pubsub/nats/go.sum b/pkg/gofr/datasource/pubsub/nats/go.sum index b26757be6b..e72dd2d362 100644 --- a/pkg/gofr/datasource/pubsub/nats/go.sum +++ b/pkg/gofr/datasource/pubsub/nats/go.sum @@ -17,8 +17,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= -github.com/klauspost/compress v1.19.0 h1:sXLILfc9jV2QYWkzFOPWStmcUVH2RHEB1JCdY2oVvCQ= -github.com/klauspost/compress v1.19.0/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/klauspost/compress v1.19.2 h1:hMRETovs/pu/dVWN7zIT1PGG8t509MwT6bO7XSi26R8= +github.com/klauspost/compress v1.19.2/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= @@ -27,8 +27,8 @@ github.com/minio/highwayhash v1.0.4 h1:asJizugGgchQod2ja9NJlGOWq4s7KsAWr5XUc9Clg github.com/minio/highwayhash v1.0.4/go.mod h1:GGYsuwP/fPD6Y9hMiXuapVvlIUEhFhMTh0rxU3ik1LQ= github.com/nats-io/jwt/v2 v2.8.2 h1:XXRgB60MSTnqsRwejQurVDs/hcv2dkt+86GjI+I/bMc= github.com/nats-io/jwt/v2 v2.8.2/go.mod h1:Ag/56sq9OblL4JgdYufDd16Egb17Kr/8WwwuO/forVc= -github.com/nats-io/nats-server/v2 v2.14.4 h1:efgjZ8cdExAKRuqSg8UPJFprb+l7NlBtSDPhDlw3rO4= -github.com/nats-io/nats-server/v2 v2.14.4/go.mod h1:BltdpOYestjbtQSnVO2zGHdg5SGBZjt+GYTgB9LZq/I= +github.com/nats-io/nats-server/v2 v2.14.5 h1:M6yeo/Xb7khi97RSEVELof3DForDqmYza3P4tHCPFWw= +github.com/nats-io/nats-server/v2 v2.14.5/go.mod h1:1D3iocrisKvWaD1B/imqarTqmaGrWMqALMLbEDo3v7Q= github.com/nats-io/nats.go v1.52.0 h1:n3avV4VBsCgsdwh71TppsTwtv+QdPs7ntSKM8qJLGsc= github.com/nats-io/nats.go v1.52.0/go.mod h1:26HypzazeOkyO3/mqd1zZd53STJN0EjCYF9Uy2ZOBno= github.com/nats-io/nkeys v0.4.16 h1:rd5oAuLOb8mnAycB0xleuEBNS1pVVnN0fv/FF34Eypg= @@ -61,8 +61,8 @@ go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= gofr.dev v1.57.0 h1:4rsJ6VI3Yk7febCFtszhpKU46H8vJWTLmeICwGgZh64= gofr.dev v1.57.0/go.mod h1:bnPTbwKQbUZAMb4bZNWOjNE+LZJP1WdayrZuSakTUXk= -golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= -golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= +golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= +golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= diff --git a/pkg/gofr/datasource/pubsub/sqs/go.mod b/pkg/gofr/datasource/pubsub/sqs/go.mod index 7e0877f5cb..05256595d2 100644 --- a/pkg/gofr/datasource/pubsub/sqs/go.mod +++ b/pkg/gofr/datasource/pubsub/sqs/go.mod @@ -3,9 +3,9 @@ module gofr.dev/pkg/gofr/datasource/pubsub/sqs go 1.26.0 require ( - github.com/aws/aws-sdk-go-v2 v1.43.3 - github.com/aws/aws-sdk-go-v2/config v1.32.34 - github.com/aws/aws-sdk-go-v2/credentials v1.19.33 + github.com/aws/aws-sdk-go-v2 v1.43.6 + github.com/aws/aws-sdk-go-v2/config v1.32.37 + github.com/aws/aws-sdk-go-v2/credentials v1.19.36 github.com/aws/aws-sdk-go-v2/service/sqs v1.45.1 github.com/stretchr/testify v1.11.1 go.opentelemetry.io/otel v1.44.0 @@ -15,17 +15,17 @@ require ( ) require ( - github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.34 // indirect - github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.34 // indirect - github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.34 // indirect - github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.35 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.15 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.34 // indirect - github.com/aws/aws-sdk-go-v2/service/signin v1.5.3 // indirect - github.com/aws/aws-sdk-go-v2/service/sso v1.33.3 // indirect - github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.3 // indirect - github.com/aws/aws-sdk-go-v2/service/sts v1.45.3 // indirect - github.com/aws/smithy-go v1.27.6 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.37 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.37 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.37 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.38 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.17 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.37 // indirect + github.com/aws/aws-sdk-go-v2/service/signin v1.5.6 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.33.6 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.6 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.45.6 // indirect + github.com/aws/smithy-go v1.27.8 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/go-logr/logr v1.4.3 // indirect diff --git a/pkg/gofr/datasource/pubsub/sqs/go.sum b/pkg/gofr/datasource/pubsub/sqs/go.sum index da206438bb..f89d0421d4 100644 --- a/pkg/gofr/datasource/pubsub/sqs/go.sum +++ b/pkg/gofr/datasource/pubsub/sqs/go.sum @@ -1,33 +1,33 @@ -github.com/aws/aws-sdk-go-v2 v1.43.3 h1:XJIcfv8uDs2ukdQsoAC8/Ebu1ejxwzlayl2ZsiFns2A= -github.com/aws/aws-sdk-go-v2 v1.43.3/go.mod h1:70vwSy16txshwG+g55WkpgPKDIByzHI8ccBsOteo3bQ= -github.com/aws/aws-sdk-go-v2/config v1.32.34 h1:o+YAizrX562nEZXaB38uYTK8RvIsvW0uuRP+e5e0Pfk= -github.com/aws/aws-sdk-go-v2/config v1.32.34/go.mod h1:wc0zYRChOniiufvdWiRVf3jgXSgbkvaD683IHHHc2ZQ= -github.com/aws/aws-sdk-go-v2/credentials v1.19.33 h1:/e5V3EWfeDiW6cuRxHsC8gbwko4/vvVYPJR2afBKFFY= -github.com/aws/aws-sdk-go-v2/credentials v1.19.33/go.mod h1:ZxAmkcyOM9beY/WO9oxp2oVPXiP3rq5N1/p4NbenJdE= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.34 h1:1EsGke6rTD2CG3j2MMVB77n6Q+FlbQWYI/dFdLWBNtM= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.34/go.mod h1:5B1Z/QbaWzqoWRzYxZfmCbDDRcvUHcfAIQw/S+KfDmc= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.34 h1:vuIfjzoeqhQMGJyOBU3t0ZEjn2jrN8Bbg1N4CgjzM5Q= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.34/go.mod h1:hP28cN4CPJLZHirdQPrZR50JcLN4ApRJP2tzG8cRlhY= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.34 h1:9faHsnqxJ1vDvB4wMZy/ajIDyz5QhllQjjc72RJpXAw= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.34/go.mod h1:Yp6nIyejpa23nzlB/LhT63KTla9Jdi06nv/HH/OkAH8= -github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.35 h1:Oe8gMKJLO5awqpa5EhAGKVnBv1s+brdWVuxM2mDa7zA= -github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.35/go.mod h1:FZevcG9cOST/FWAAUhHIchjR9fXFXFRCWodOhx+PDLA= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.15 h1:JJLBQxwY+AFwuPAi5ivGc1ChnTdUt4cXMv7e76m2c/Y= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.15/go.mod h1:lQknBIe78MVL0cQOQDlag8KGflMbMEVFx9mB6O8ENvk= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.34 h1:sYg4qHWLqsjp15PzX7XCOHSOgKEGoZ5vQY43VvZ1pas= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.34/go.mod h1:N58SSz3roKf1HzW5qRaOiyk6MbDLTKgLPvlTfJ90iyI= -github.com/aws/aws-sdk-go-v2/service/signin v1.5.3 h1:togAtAmgV5IGMnQDuBDJeM8z5Y5RN6G7xeOgphWz+Yc= -github.com/aws/aws-sdk-go-v2/service/signin v1.5.3/go.mod h1:T7xKUUUvN7W3RW8UmMvKnD12xqh+Ux2gCPHPhnt64Dg= +github.com/aws/aws-sdk-go-v2 v1.43.6 h1:RrmFcqCBxkJuf7g1axVo5krB4jM/AO8r5e5oujrgdoQ= +github.com/aws/aws-sdk-go-v2 v1.43.6/go.mod h1:tXpPM+v0D1lndmga+HqqLDIzUFJlEeR21aspVklHF00= +github.com/aws/aws-sdk-go-v2/config v1.32.37 h1:Ljl7LOJB6ym0liuEl0+TZ3d7f5I8MEZN1Cj9PINlj/g= +github.com/aws/aws-sdk-go-v2/config v1.32.37/go.mod h1:WJ7pe7ZPpmG8Q5kKS53zeypIV4FBGACxmte8Uc6SgUc= +github.com/aws/aws-sdk-go-v2/credentials v1.19.36 h1:84s5xMme6ENYEdKG8rsbSFFg/8+lbHBeM9QYSO0gnDk= +github.com/aws/aws-sdk-go-v2/credentials v1.19.36/go.mod h1:c46BLdagDLIswjgt+GeQOslXgeS0E6wCacs5yZbxPGk= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.37 h1:b5tb+CZItBkydC7r3hTNdSO3pszG1R2EtnA+7TePQPk= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.37/go.mod h1:ZQ+6SU9X0oz6+7MUCSswv9Mjci4eaqZr21HI2RVy/yA= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.37 h1:lznzIOvvbqjfe8UAaciCRJgBgJsxuTROKlhZuXQWfv8= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.37/go.mod h1:otfkzyfQeMMLZAqX59GSXTL3o22BR/l6HFaRzzbWSqA= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.37 h1:zCEORWo0eU0gDjG+IyApE/2B+ZGG1m+GU7B263XV8ds= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.37/go.mod h1:i6c0PEl3TNOWxRbQ++KQcVenPWS/GoQeiklKhNuqzJ8= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.38 h1:A3UAuCmx7LyUcrixBTzKJYYIUZ2yTvn6ZhT8PB+7APk= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.38/go.mod h1:1PDUYG9Z+JrbbsobsAZHjWOm9QBT/djiK3QbykTL5Z4= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.17 h1:OvYZOB3qA6zvfdRFiRFRzVSiElMYrz3GdntkXZxlp1o= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.17/go.mod h1:JgR/2Ew50ACfIWau1oeMRX59tMtC0kM+PYQGEaT04cY= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.37 h1:a3D4AjrOrTrP8+d9ILBthqrElf0z1JNol09Xvnwcys8= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.37/go.mod h1:ky0gTu+ukvUTuUKFIpp6Wid4oninrkCyvbFkVs0kpHM= +github.com/aws/aws-sdk-go-v2/service/signin v1.5.6 h1:i68sFvXidKlkiSvI7d7Ilc1/UvW4CtBOaivH7jhG4fs= +github.com/aws/aws-sdk-go-v2/service/signin v1.5.6/go.mod h1:/h7Obr9WTtzbjTHGASRQwLN7Bupw+TC3x8x7fyx39hE= github.com/aws/aws-sdk-go-v2/service/sqs v1.45.1 h1:J4/Py6AKAWeaLqQnvQ8L9fq3AQsVgpuGCQ7D8rDDMBg= github.com/aws/aws-sdk-go-v2/service/sqs v1.45.1/go.mod h1:JISE0m3JPVhirZEVIAUyK4C62n87tU4BZmUa9Ozc2to= -github.com/aws/aws-sdk-go-v2/service/sso v1.33.3 h1:YjH64OUytnWZBHUtM9GMyi4ZWBiSQdEJkZuPykOIe44= -github.com/aws/aws-sdk-go-v2/service/sso v1.33.3/go.mod h1:5qoHcDZDTSJotoKk1bvVRPv1MXaL/NhfY9ng8D1g/ig= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.3 h1:A4o1di/XGaqtw6r3toSBrFX2U7mVSLqg7jo9wL4I+cU= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.3/go.mod h1:sKuKz2kHtrGVtFu34vbM3LWSA9CKD9YZUmm6e5PPqRA= -github.com/aws/aws-sdk-go-v2/service/sts v1.45.3 h1:Fi7+DiKN1+QphlajvE6FqeZ8GRbnnRul7zTdUiRpbGc= -github.com/aws/aws-sdk-go-v2/service/sts v1.45.3/go.mod h1:KCc3e27fHZUGtzpek7wZcp6dyCpGkJJo/+3PBujh/yU= -github.com/aws/smithy-go v1.27.6 h1:0zjT8jgK3jbrTT7JJ3EE6JsMhX8JTrZ+f1sEndYDXrA= -github.com/aws/smithy-go v1.27.6/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= +github.com/aws/aws-sdk-go-v2/service/sso v1.33.6 h1:tpfGChmjUmv3W9WlRvy+stwKDTbFFdq8Zk9DbFPrfMU= +github.com/aws/aws-sdk-go-v2/service/sso v1.33.6/go.mod h1:CSjiDzmG/lsKkTOYjbkM+duLmRlW+LOxD64Na44ijnI= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.6 h1:49BBtY68A+KJCQ3a2F3eUe6ROsKucxUdfHKoqorc0wI= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.6/go.mod h1:ptG2hbs7QltE1GcQY0MpS4bfrc51KCnBXUr7OT1EEfE= +github.com/aws/aws-sdk-go-v2/service/sts v1.45.6 h1:JvExZWabChDM0qJAirQYGfOYo0ndT3edXj+fqSPNjkE= +github.com/aws/aws-sdk-go-v2/service/sts v1.45.6/go.mod h1:XZcaQkV2cItp6yEkrwljyaPOf22RuX7T43jxap/FOmM= +github.com/aws/smithy-go v1.27.8 h1:FR0dxZfIlV7Z8eh2iHfIofdunw382XsDV3Mxt9nUvRY= +github.com/aws/smithy-go v1.27.8/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= diff --git a/pkg/gofr/datasource/solr/go.mod b/pkg/gofr/datasource/solr/go.mod index 31e60387c8..e247268604 100644 --- a/pkg/gofr/datasource/solr/go.mod +++ b/pkg/gofr/datasource/solr/go.mod @@ -4,21 +4,19 @@ go 1.26.0 require ( github.com/stretchr/testify v1.11.1 - go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.69.0 - go.opentelemetry.io/otel v1.44.0 - go.opentelemetry.io/otel/trace v1.44.0 + go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.70.0 + go.opentelemetry.io/otel v1.45.0 + go.opentelemetry.io/otel/trace v1.45.0 go.uber.org/mock v0.6.0 ) require ( github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect - github.com/felixge/httpsnoop v1.1.0 // indirect - github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/logr v1.4.4 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect - go.opentelemetry.io/otel/metric v1.44.0 // indirect - golang.org/x/sys v0.46.0 // indirect + go.opentelemetry.io/otel/metric v1.45.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/pkg/gofr/datasource/solr/go.sum b/pkg/gofr/datasource/solr/go.sum index 7e1b74477f..aca6ada8c3 100644 --- a/pkg/gofr/datasource/solr/go.sum +++ b/pkg/gofr/datasource/solr/go.sum @@ -5,8 +5,8 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8Yc github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc= github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= -github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= -github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= +github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= @@ -25,24 +25,24 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.69.0 h1:MCcYL7J6Vt/X0kjqbMZkekCmwsurbQRbL69vkiye2lk= -go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.69.0/go.mod h1:3jnStNwSufK+f5ktjL4EPcwtig4rtd81NS70lqHuXl8= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 h1:8tvICD4vSTOOsNrsI4Ljf6C+6UKvpTEH5XY3JMoyPoo= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0/go.mod h1:z9+yiacE0IHRqM4qFfkbt/JYlmYXgss8GY/jXoNuPJI= -go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= -go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= -go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= -go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= -go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= -go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= -go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= -go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= -go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= -go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= +go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.70.0 h1:aVgLpGksz0vjoe6OynycqX8daNOAxJx5ZEhJXIXOVIU= +go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.70.0/go.mod h1:kmJlX6WuTrAH1fOCSbPJFrSnUagB8c3SY3E87It3JD8= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0 h1:LMuyCAyfalSjDyjdC65nK6N0zoTT63+E/u95X0JovZI= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0/go.mod h1:085m8qbm4hgc8rZWGDEa4vmyyo2c3nPxUslYUKUIU04= +go.opentelemetry.io/otel v1.45.0 h1:pdrWmLHofpubmArBv1LgFSv1Z0Ie/ppdZzu+kUN5EeU= +go.opentelemetry.io/otel v1.45.0/go.mod h1:XZxIqPapzEYnhNSScF5DIqXhm/rYi0FzCe2XddAwZfQ= +go.opentelemetry.io/otel/metric v1.45.0 h1:7Eg1uH7CJ5cXv9is6tnBe1FI6rj1nwUdbFypRm3br/M= +go.opentelemetry.io/otel/metric v1.45.0/go.mod h1:HAPbm1nd3p1PmFH7v2dR+6BjXxw+Lq4a2+pndMAm08s= +go.opentelemetry.io/otel/sdk v1.45.0 h1:4VVSMgQ83dUgW2aoX5f6JgLvHwIvzcuLnF9lUdCSpCw= +go.opentelemetry.io/otel/sdk v1.45.0/go.mod h1:Sr40LgXV7DsKMMJMKOhUWOgMWTfAaqvm2kF0g7ilwuA= +go.opentelemetry.io/otel/sdk/metric v1.45.0 h1:oVFszMfyj1Am6s24Vtc7wBb8BKLcwepJjNEYILuiE3o= +go.opentelemetry.io/otel/sdk/metric v1.45.0/go.mod h1:vUWUxDZvu1WVRj8JA8S0AdhsPrZoDpA2DdZauIh4mDA= +go.opentelemetry.io/otel/trace v1.45.0 h1:l/mP6Uv7oNO7/TblbhpbgMidxhq1uO/rPsikOyVhxag= +go.opentelemetry.io/otel/trace v1.45.0/go.mod h1:qoJJA2xNMnxRrdISU/kLtfUH2wNeQbiv+jhs/CxI8bc= go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= -golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= -golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= From 24d51d6e362ff308d7432c8228943f6d8aeaeb50 Mon Sep 17 00:00:00 2001 From: Akshat Singhal <65562230+akshat-kumar-singhal@users.noreply.github.com> Date: Tue, 18 Aug 2026 19:14:52 +0530 Subject: [PATCH 09/20] fix(ci): wait for Example-Unit-Testing services to be ready, and pin Zipkin (#3868) (#3939) --- .github/workflows/go.yml | 51 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 49 insertions(+), 2 deletions(-) diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 06905a63ea..8461f1d454 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -38,6 +38,14 @@ jobs: # Define service containers that tests depend on services: # Kafka service + # + # NOTE: the `bitnamilegacy` namespace is a frozen archive — Bitnami state + # it receives no further updates and "may be removed in the future". This + # image therefore still needs to move (to apache/kafka, or a mirror under + # gofr-dev), which is tracked separately in #3868: that migration rewrites + # the whole KAFKA_CFG_* block below, since those names are a Bitnami + # convention, and it cannot be validated without a live CI run. Kept here + # deliberately so this PR stays reviewable. kafka: image: bitnamilegacy/kafka:3.4.1 ports: @@ -54,13 +62,30 @@ jobs: KAFKA_CFG_CONTROLLER_QUORUM_VOTERS: 1@127.0.0.1:9093 ALLOW_PLAINTEXT_LISTENER: yes KAFKA_CFG_NODE_ID: 1 + # kafka-topics.sh is on PATH in this image (/opt/bitnami/kafka/bin) and + # returns non-zero until the broker accepts connections. The 10s + # timeout is deliberate — the check starts a JVM, so the 5s used for + # redis/mysql would risk timing out on a broker that is actually fine. + options: >- + --health-cmd "kafka-topics.sh --bootstrap-server 127.0.0.1:9092 --list" + --health-interval=10s + --health-timeout=10s + --health-retries=10 # Redis service redis: image: redis:7.0.5 ports: - "2002:6379" - options: "--entrypoint redis-server" + # Without a health check the runner waits for the container to start, + # not for the service inside it to accept connections. The health + # options are appended to the existing --entrypoint override. + options: >- + --entrypoint redis-server + --health-cmd "redis-cli ping" + --health-interval=10s + --health-timeout=5s + --health-retries=5 # MySQL service mysql: @@ -70,6 +95,14 @@ jobs: env: MYSQL_ROOT_PASSWORD: "password" MYSQL_DATABASE: "test" + # MySQL is the one that takes meaningfully long to accept connections + # after the container starts, so it is the one most likely to have been + # racing the tests. + options: >- + --health-cmd "mysqladmin ping -h 127.0.0.1 -ppassword" + --health-interval=10s + --health-timeout=5s + --health-retries=10 # Steps to execute for this job steps: @@ -95,8 +128,22 @@ jobs: run: | go mod download + # Same treatment as the MinIO step below: pin the image and wait for the + # service, not for `docker run -d` to return. `:latest` also meant the + # image could change under the repo without any commit. - name: Start Zipkin - run: docker run -d -p 2005:9411 openzipkin/zipkin:latest + run: | + docker run -d --name zipkin -p 2005:9411 \ + openzipkin/zipkin:3.6.1@sha256:d17e856dcbba7ffeefbbfc252f89ab78a4ab6faed47e646d46daad78f91b5ee2 + for i in $(seq 1 30); do + if curl -fs http://localhost:2005/health >/dev/null; then + echo "Zipkin is ready"; exit 0 + fi + echo "waiting for Zipkin... ($i)"; sleep 2 + done + echo "::error::Zipkin failed to become ready after 60s" + docker logs zipkin || true + exit 1 # MinIO can't run as a GitHub Actions `services:` container because the # stock image needs a `server /data` command, which services can't set. From 6ac158698455a1b394367a693b5b9bb3f3705ec5 Mon Sep 17 00:00:00 2001 From: Akshat Singhal <65562230+akshat-kumar-singhal@users.noreply.github.com> Date: Fri, 21 Aug 2026 13:12:38 +0530 Subject: [PATCH 10/20] chore(ci): add concurrency groups and job timeouts, pin actions by SHA (#3865, #3867) (#3936) --- .github/workflows/go.yml | 40 ++++++++++++++++++++++++----- .github/workflows/typos.yml | 18 ++++++++++++- .github/workflows/website-prod.yml | 15 +++++++++-- .github/workflows/website-stage.yml | 14 ++++++++-- 4 files changed, 76 insertions(+), 11 deletions(-) diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 8461f1d454..3f1d029f3e 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -22,12 +22,29 @@ on: - 'docs/**' # Ignore changes to docs folder - '**/*.md' +# One in-flight run per ref. Pushing twice to a PR branch otherwise starts a +# second full 14-job matrix — including the Kafka/Redis/MySQL services and the +# Zipkin/MinIO containers — while the first is still running, and only the +# newer result is ever looked at. +# +# Cancellation is limited to pull_request on purpose. On `development` the runs +# are serialized merges rather than rapid re-pushes, so there is little to save, +# and `upload_coverage` publishes to qlty per push — cancelling that loses the +# coverage data point for a commit that will not be built again. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + # Define the jobs that this workflow will run jobs: # Job for testing the examples directory Example-Unit-Testing: name: Example Unit Testing (v${{ matrix.go-version }})🛠 runs-on: ubuntu-latest + # Bounds a hung job at 30m instead of GitHub's 6h default. The retry action + # below caps the test step itself (5m x 2 attempts), but `Get dependencies`, + # the MinIO readiness poll and the s3 example test are otherwise unbounded. + timeout-minutes: 30 # Define a matrix strategy to test against multiple Go versions strategy: matrix: @@ -171,7 +188,7 @@ jobs: - name: Test id: test - uses: nick-fields/retry@v4 + uses: nick-fields/retry@ad984534de44a9489a53aefd81eb77f87c70dc60 # v4.0.0 with: timeout_minutes: 5 # Maximum time for the tests to run # No retry. The example tests used to race their own server's boot — every one of them @@ -221,6 +238,7 @@ jobs: PKG-Unit-Testing: name: PKG Unit Testing (v${{ matrix.go-version }})🛠 runs-on: ubuntu-latest + timeout-minutes: 30 strategy: matrix: go-version: ['1.26','1.25', '1.24'] @@ -251,7 +269,7 @@ jobs: # Run pkg tests with automatic retry logic - name: Test with Retry Logic id: test - uses: nick-fields/retry@v4 + uses: nick-fields/retry@ad984534de44a9489a53aefd81eb77f87c70dc60 # v4.0.0 with: timeout_minutes: 5 max_attempts: 2 @@ -308,6 +326,7 @@ jobs: parse_coverage: name: Code Coverage runs-on: ubuntu-latest + timeout-minutes: 10 # This job runs after both Example and PKG testing are complete needs: [ Example-Unit-Testing,PKG-Unit-Testing ] steps: @@ -346,6 +365,7 @@ jobs: Submodule-Unit-Testing: name: Submodule Unit Testing (v${{ matrix.go-version }})🛠 runs-on: ubuntu-latest + timeout-minutes: 30 strategy: matrix: go-version: ['1.26','1.25', '1.24'] @@ -380,7 +400,7 @@ jobs: # Test all submodules in parallel with retry logic - name: Test Submodules with Retry and Parallelism id: test_submodules - uses: nick-fields/retry@v4 + uses: nick-fields/retry@ad984534de44a9489a53aefd81eb77f87c70dc60 # v4.0.0 with: timeout_minutes: 5 max_attempts: 2 @@ -442,6 +462,7 @@ jobs: upload_coverage: name: Upload Coverage📊 runs-on: ubuntu-latest + timeout-minutes: 15 env: QLTY_TOKEN: ${{ secrets.QLTY_TOKEN }} QLTY_COVERAGE_TOKEN: ${{ secrets.QLTY_TOKEN }} @@ -488,6 +509,9 @@ jobs: code_quality: name: Code Quality🎖️ runs-on: ubuntu-latest + # golangci-lint gets --timeout=5m below; this bounds checkout, module + # download and the changed-files scan around it. + timeout-minutes: 20 outputs: modules: ${{ steps.changed-submodules.outputs.modules }} has_modules: ${{ steps.changed-submodules.outputs.has_modules }} @@ -509,7 +533,7 @@ jobs: # Use the official golangci-lint action for the root module # This action automatically detects changed files and only reports new issues - name: Lint Root Module - uses: golangci/golangci-lint-action@v9 + uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a # v9.3.0 with: version: v2.12.2 only-new-issues: true @@ -519,7 +543,7 @@ jobs: # This implements a changed-files based approach as suggested by the maintainer - name: Get Changed Files id: changed-files - uses: tj-actions/changed-files@v47 + uses: tj-actions/changed-files@24d32ffd492484c1d75e0c0b894501ddb9d30d62 # v47.0.0 with: files: | pkg/**/*.go @@ -581,6 +605,9 @@ jobs: lint_changed_submodules: name: Lint Submodules🔍 runs-on: ubuntu-latest + # Must exceed the --timeout=9m passed to golangci-lint below, so a genuine + # lint timeout still reports as a lint failure rather than a killed job. + timeout-minutes: 20 needs: code_quality if: needs.code_quality.outputs.has_modules == 'true' strategy: @@ -605,7 +632,7 @@ jobs: # Use the official golangci-lint action for this submodule - name: Lint ${{ matrix.module }} - uses: golangci/golangci-lint-action@v9 + uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a # v9.3.0 with: version: v2.12.2 working-directory: ${{ matrix.module }} @@ -616,6 +643,7 @@ jobs: linting_party: name: Linting Party🥳 runs-on: ubuntu-latest + timeout-minutes: 10 steps: - name: Check out code uses: actions/checkout@v7 diff --git a/.github/workflows/typos.yml b/.github/workflows/typos.yml index 80d41a0a0c..a0f331b5a5 100644 --- a/.github/workflows/typos.yml +++ b/.github/workflows/typos.yml @@ -1,14 +1,30 @@ name: Typos Check +# Mirrors go.yml's trigger block. The previous bare `push:`/`pull_request:` had +# no branch filter, so a PR raised from a branch in this repo ran the check +# twice — once for the push, once for the pull_request. Unlike go.yml there is +# no paths-ignore: prose is exactly what this check is for. on: push: + branches: + - main + - development pull_request: + branches: + - main + - development + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: typos: runs-on: ubuntu-latest + timeout-minutes: 10 permissions: contents: read steps: - name: Checkout Code uses: actions/checkout@v7 - name: typos-action - uses: crate-ci/typos@v1.49.0 \ No newline at end of file + uses: crate-ci/typos@8a48f81b6c64dcfea44b3633223084c4be58ac5f # v1.49.0 diff --git a/.github/workflows/website-prod.yml b/.github/workflows/website-prod.yml index 7ee3ff7bb7..5151b3e876 100644 --- a/.github/workflows/website-prod.yml +++ b/.github/workflows/website-prod.yml @@ -7,6 +7,14 @@ on: tags: - "v*.*.*" +# Serialize deploys without ever cancelling one. `cancel-in-progress: false` is +# deliberate and the opposite of go.yml: killing a run between `docker push` and +# `kubectl set image` leaves the cluster on the old image with a new tag already +# in GAR, which is worse than simply queueing behind the run in flight. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + env: APP_NAME: gofr-website WEBSITE_REGISTRY: ghcr.io @@ -23,6 +31,8 @@ jobs: contents: read packages: write runs-on: ubuntu-latest + # yarn install + refresh-data + two docker builds + a push. + timeout-minutes: 45 outputs: image: ${{ steps.output-image.outputs.image }} name: 🐳 Dockerize @@ -73,7 +83,7 @@ jobs: continue-on-error: true - name: Login to GAR - uses: docker/login-action@v4 + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 with: registry: us-central1-docker.pkg.dev username: _json_key @@ -125,6 +135,7 @@ jobs: deployment: runs-on: ubuntu-latest + timeout-minutes: 15 name: 🚀 Deploy-Prod needs: dockerize container: @@ -138,7 +149,7 @@ jobs: uses: actions/checkout@v7 - name: Authorize to GCP service account - uses: google-github-actions/auth@v3 + uses: google-github-actions/auth@7c6bc770dae815cd3e89ee6cdf493a5fab2cc093 # v3.0.0 with: credentials_json: ${{ secrets.GOFR_WEBSITE_GOFR_DEV_DEPLOYMENT_KEY }} diff --git a/.github/workflows/website-stage.yml b/.github/workflows/website-stage.yml index 7c6edd916f..c49c459193 100644 --- a/.github/workflows/website-stage.yml +++ b/.github/workflows/website-stage.yml @@ -11,6 +11,13 @@ on: # waiting for the next push to development. workflow_dispatch: +# Same rationale as website-prod.yml: serialize, never cancel. A half-finished +# deploy is worse than a queued one. Kept in lockstep with prod so the two +# don't drift. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + env: APP_NAME: gofr-website WEBSITE_REGISTRY: ghcr.io @@ -28,6 +35,8 @@ jobs: contents: read packages: write runs-on: ubuntu-latest + # yarn install + refresh-data + two docker builds + a push. + timeout-minutes: 45 outputs: image: ${{ steps.output-image.outputs.image }} name: 🐳 Dockerize @@ -75,7 +84,7 @@ jobs: continue-on-error: true - name: Login to GAR - uses: docker/login-action@v4 + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 with: registry: us-central1-docker.pkg.dev username: _json_key @@ -132,6 +141,7 @@ jobs: deployment_stage: runs-on: ubuntu-latest + timeout-minutes: 15 name: 🚀 Deploy-Stage needs: dockerize container: @@ -145,7 +155,7 @@ jobs: uses: actions/checkout@v7 - name: Authorize to GCP service account - uses: google-github-actions/auth@v3 + uses: google-github-actions/auth@7c6bc770dae815cd3e89ee6cdf493a5fab2cc093 # v3.0.0 with: credentials_json: ${{ secrets.GOFR_WEBSITE_GOFR_DEV_STG_DEPLOYMENT_KEY }} From b15a97c62b3df3246fa28ac22cc3b1b7632b15aa Mon Sep 17 00:00:00 2001 From: Akshat Singhal <65562230+akshat-kumar-singhal@users.noreply.github.com> Date: Fri, 21 Aug 2026 13:22:58 +0530 Subject: [PATCH 11/20] fix(ci): stop mutating go.mod mid-run; check submodule tidiness without writing (#3869) (#3938) --- .github/workflows/go.yml | 33 +++++++++++++++++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 3f1d029f3e..72ef84b8c3 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -437,9 +437,15 @@ jobs: # Extract module name (replace / with _) module_name=$(echo "$module" | tr "/" "_") - # Download dependencies for the submodule + # Download dependencies for the submodule. Deliberately no + # `go mod tidy` here: tidy rewrites go.mod/go.sum in place, so the + # tests would run against whatever tidy resolved at that moment + # rather than against what the repo actually declares. A submodule + # with an incomplete committed go.mod would be silently repaired + # in the runner and pass, then fail for anyone who clones it — + # CI quietly fixing the exact defect it exists to report. Tidiness + # is checked without mutation in the code_quality job instead. go mod download - go mod tidy # Run tests with a focus on failed tests first go test ./... -v -short -coverprofile="${module_name}.cov" -coverpkg=./... @@ -530,6 +536,29 @@ jobs: - name: Get dependencies run: go mod download + # Replaces the signal the `go mod tidy` in Submodule-Unit-Testing was + # accidentally providing. `-diff` (Go 1.23+) reports what tidy would + # change and exits non-zero, without writing anything — so an untidy + # go.mod is now reported instead of silently repaired mid-test-run. + # + # Scoped to the pkg/ modules, which are the ones that ship. All of them + # are tidy as of this change; the examples are excluded because + # examples/using-gcp-metrics is currently untidy and settling that is a + # dependency bump, not a CI fix. + - name: Check submodule go.mod tidiness + run: | + rc=0 + while read -r mod; do + dir=$(dirname "$mod") + if ! out=$(cd "$dir" && go mod tidy -diff 2>&1); then + echo "::error file=$mod::$dir has an untidy go.mod/go.sum — run 'go mod tidy' there" + echo "$out" + rc=1 + fi + done < <(find pkg -name go.mod) + if [ "$rc" -eq 0 ]; then echo "✅ all pkg/ submodules are tidy"; fi + exit $rc + # Use the official golangci-lint action for the root module # This action automatically detects changed files and only reports new issues - name: Lint Root Module From b29f6935bae91e0a95350091f206335f002ac792 Mon Sep 17 00:00:00 2001 From: Akshat Singhal <65562230+akshat-kumar-singhal@users.noreply.github.com> Date: Fri, 21 Aug 2026 13:50:02 +0530 Subject: [PATCH 12/20] chore(deps): close the dependabot coverage gaps and retire EOL base images (#3873) (#3937) --- .github/dependabot.yml | 24 +++++++++++++++++++++ Dockerfile | 4 ++-- examples/http-server-using-redis/Dockerfile | 4 ++-- examples/http-server/Dockerfile | 4 ++-- examples/using-add-rest-handlers/Dockerfile | 4 ++-- examples/using-custom-metrics/Dockerfile | 4 ++-- examples/using-file-bind/Dockerfile | 4 ++-- examples/using-http-service/Dockerfile | 4 ++-- examples/using-migrations/Dockerfile | 4 ++-- examples/using-publisher/Dockerfile | 4 ++-- examples/using-subscriber/Dockerfile | 4 ++-- 11 files changed, 44 insertions(+), 20 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 34d85d7f33..12154b299a 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -3,6 +3,11 @@ version: 2 updates: - package-ecosystem: "gomod" open-pull-requests-limit: 10 # avoid spam, if no one reacts + # The tree has 32 go.mod directories. The globs below must cover all of + # them — `pkg/gofr/metrics/exporters/gcp` was matched by none of the + # original entries and so received no dependency updates at all, despite + # vendoring the GCP client libraries. When adding a module outside these + # paths, add its glob here too; nothing fails loudly when one is missed. directories: - "/" - "/examples/*" @@ -10,6 +15,7 @@ updates: - "/pkg/gofr/datasource/file/*" - "/pkg/gofr/datasource/kv-store/*" - "/pkg/gofr/datasource/pubsub/*" + - "/pkg/gofr/metrics/exporters/*" schedule: interval: "weekly" @@ -25,3 +31,21 @@ updates: - "minor" - "patch" + # Base images were previously unmanaged entirely, which is how + # examples/http-server sat on alpine:3.14 — end-of-support 2023-05-01, so no + # musl/busybox/OpenSSL patches for over three years. Grouped so a weekly + # patch bump across the 11 Dockerfiles arrives as one PR rather than 11. + - package-ecosystem: "docker" + open-pull-requests-limit: 10 # avoid spam, if no one reacts + directories: + - "/" + - "/docs" + - "/examples/*" + schedule: + interval: "weekly" + groups: + docker: + update-types: + - "minor" + - "patch" + diff --git a/Dockerfile b/Dockerfile index 8019b16a01..44682e5bc0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.24 +FROM golang:1.26 RUN mkdir -p /go/src/gofr.dev WORKDIR /go/src/gofr.dev @@ -6,7 +6,7 @@ COPY . . RUN go build -ldflags "-linkmode external -extldflags -static" -a examples/http-server/main.go -FROM alpine:latest +FROM alpine:3.24 RUN apk add --no-cache tzdata ca-certificates COPY --from=0 /go/src/gofr.dev/main /main EXPOSE 8000 diff --git a/examples/http-server-using-redis/Dockerfile b/examples/http-server-using-redis/Dockerfile index 1388a78964..6322ceb478 100644 --- a/examples/http-server-using-redis/Dockerfile +++ b/examples/http-server-using-redis/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.24 +FROM golang:1.26 RUN mkdir /src/ WORKDIR /src/ @@ -6,7 +6,7 @@ COPY . . RUN go get ./... RUN go build -ldflags "-linkmode external -extldflags -static" -a main.go -FROM alpine:latest +FROM alpine:3.24 RUN apk add --no-cache tzdata ca-certificates COPY --from=0 /src/main /main COPY --from=0 /src/configs /configs diff --git a/examples/http-server/Dockerfile b/examples/http-server/Dockerfile index 98d86c002e..ef54d07e5e 100644 --- a/examples/http-server/Dockerfile +++ b/examples/http-server/Dockerfile @@ -1,5 +1,5 @@ # Build stage -FROM golang:1.25-alpine AS build +FROM golang:1.26-alpine AS build RUN apk add --no-cache build-base WORKDIR /src @@ -16,7 +16,7 @@ WORKDIR /src/examples/http-server RUN CGO_ENABLED=0 go build -a -o /app/main . # Final stage -FROM alpine:3.14 +FROM alpine:3.24 RUN apk add --no-cache tzdata ca-certificates COPY --from=build /app/main /main COPY --from=build /src/examples/http-server/configs /configs diff --git a/examples/using-add-rest-handlers/Dockerfile b/examples/using-add-rest-handlers/Dockerfile index 94bb8215bc..9bc338f9fa 100644 --- a/examples/using-add-rest-handlers/Dockerfile +++ b/examples/using-add-rest-handlers/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.24 +FROM golang:1.26 RUN mkdir /src/ WORKDIR /src/ @@ -6,7 +6,7 @@ COPY . . RUN go get ./... RUN go build -ldflags "-linkmode external -extldflags -static" -a main.go -FROM alpine:latest +FROM alpine:3.24 RUN apk add --no-cache tzdata ca-certificates COPY --from=0 /src/main /main COPY --from=0 /src/configs /configs diff --git a/examples/using-custom-metrics/Dockerfile b/examples/using-custom-metrics/Dockerfile index 3fcf208701..5906693c4a 100644 --- a/examples/using-custom-metrics/Dockerfile +++ b/examples/using-custom-metrics/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.24 +FROM golang:1.26 RUN mkdir /src/ WORKDIR /src/ @@ -6,7 +6,7 @@ COPY . . RUN go get ./... RUN go build -ldflags "-linkmode external -extldflags -static" -a main.go -FROM alpine:latest +FROM alpine:3.24 RUN apk add --no-cache tzdata ca-certificates COPY --from=0 /src/main /main COPY --from=0 /src/configs /configs diff --git a/examples/using-file-bind/Dockerfile b/examples/using-file-bind/Dockerfile index fc35573b16..b48dd8db33 100644 --- a/examples/using-file-bind/Dockerfile +++ b/examples/using-file-bind/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.24 +FROM golang:1.26 RUN mkdir /src/ WORKDIR /src/ @@ -6,7 +6,7 @@ COPY . . RUN go get ./... RUN go build -ldflags "-linkmode external -extldflags -static" -a main.go -FROM alpine:latest +FROM alpine:3.24 RUN apk add --no-cache tzdata ca-certificates COPY --from=0 /src/main /main COPY --from=0 /src/configs /configs diff --git a/examples/using-http-service/Dockerfile b/examples/using-http-service/Dockerfile index 6e4a486f68..9a03b54d5d 100644 --- a/examples/using-http-service/Dockerfile +++ b/examples/using-http-service/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.24 +FROM golang:1.26 RUN mkdir /src/ WORKDIR /src/ @@ -6,7 +6,7 @@ COPY . . RUN go get ./... RUN go build -ldflags "-linkmode external -extldflags -static" -a main.go -FROM alpine:3.23.3 +FROM alpine:3.24 RUN apk add --no-cache tzdata ca-certificates COPY --from=0 /src/main /main COPY --from=0 /src/configs /configs diff --git a/examples/using-migrations/Dockerfile b/examples/using-migrations/Dockerfile index 489ee14d0c..e92cede711 100644 --- a/examples/using-migrations/Dockerfile +++ b/examples/using-migrations/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.24 +FROM golang:1.26 RUN mkdir /src/ WORKDIR /src/ @@ -6,7 +6,7 @@ COPY . . RUN go get ./... RUN go build -ldflags "-linkmode external -extldflags -static" -a main.go -FROM alpine:latest +FROM alpine:3.24 RUN apk add --no-cache tzdata ca-certificates COPY --from=0 /src/main /main COPY --from=0 /src/configs /configs diff --git a/examples/using-publisher/Dockerfile b/examples/using-publisher/Dockerfile index 81b901d64e..b92e2e940c 100644 --- a/examples/using-publisher/Dockerfile +++ b/examples/using-publisher/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.24 +FROM golang:1.26 RUN mkdir /src/ WORKDIR /src/ @@ -6,7 +6,7 @@ COPY . . RUN go get ./... RUN go build -ldflags "-linkmode external -extldflags -static" -a main.go -FROM alpine:latest +FROM alpine:3.24 RUN apk add --no-cache tzdata ca-certificates COPY --from=0 /src/main /main COPY --from=0 /src/configs /configs diff --git a/examples/using-subscriber/Dockerfile b/examples/using-subscriber/Dockerfile index ddff7e4ad9..1ade57a4af 100644 --- a/examples/using-subscriber/Dockerfile +++ b/examples/using-subscriber/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.24 +FROM golang:1.26 RUN mkdir /src/ WORKDIR /src/ @@ -6,7 +6,7 @@ COPY . . RUN go get ./... RUN go build -ldflags "-linkmode external -extldflags -static" -a main.go -FROM alpine:latest +FROM alpine:3.24 RUN apk add --no-cache tzdata ca-certificates COPY --from=0 /src/main /main COPY --from=0 /src/configs /configs From 1eea201866a6a9d834c3051f04d80e74ba6be4eb Mon Sep 17 00:00:00 2001 From: Umang Mundhra Date: Mon, 24 Aug 2026 16:48:24 +0530 Subject: [PATCH 13/20] chore(deps): bump tj-actions/changed-files 47.0.0 -> 47.0.6 (#4049) --- .github/workflows/go.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 72ef84b8c3..3eabb6da33 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -572,7 +572,7 @@ jobs: # This implements a changed-files based approach as suggested by the maintainer - name: Get Changed Files id: changed-files - uses: tj-actions/changed-files@24d32ffd492484c1d75e0c0b894501ddb9d30d62 # v47.0.0 + uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6 with: files: | pkg/**/*.go From a0dbf76facf14c5fc57f3c701e38c408f2ed2e50 Mon Sep 17 00:00:00 2001 From: Umang Mundhra Date: Tue, 25 Aug 2026 10:20:35 +0530 Subject: [PATCH 14/20] chore(deps): bump redis/go-redis/v9 9.21.0 -> 9.22.0 + regenerate mock (#4051) --- go.mod | 2 +- go.sum | 4 +- pkg/gofr/container/mock_datasources.go | 319 +++++++++++++++++++++++++ 3 files changed, 322 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 411fe01715..70c8bdaab3 100644 --- a/go.mod +++ b/go.mod @@ -24,7 +24,7 @@ require ( github.com/prometheus/client_golang v1.24.1 github.com/prometheus/otlptranslator v1.0.0 github.com/redis/go-redis/extra/redisotel/v9 v9.21.0 - github.com/redis/go-redis/v9 v9.21.0 + github.com/redis/go-redis/v9 v9.22.0 github.com/segmentio/kafka-go v0.4.51 github.com/stretchr/testify v1.11.1 github.com/vektah/gqlparser/v2 v2.5.36 diff --git a/go.sum b/go.sum index 91029e3b6f..f6ec1e7426 100644 --- a/go.sum +++ b/go.sum @@ -185,8 +185,8 @@ github.com/redis/go-redis/extra/rediscmd/v9 v9.21.0 h1:jsV3tyMeJrEoc2f3EhNf7qoBW github.com/redis/go-redis/extra/rediscmd/v9 v9.21.0/go.mod h1:e5t17bY9cEpVV+xw2U7jsPOKkXBtL5IQmNVABShnHUk= github.com/redis/go-redis/extra/redisotel/v9 v9.21.0 h1:36qq3rbF2If2CP0zGHHF8o/4XDluErn6DD0c9/L2iNI= github.com/redis/go-redis/extra/redisotel/v9 v9.21.0/go.mod h1:7y2cVB/LXXLHqHOO2jCVzBqimIQk1w7Rp9WSpyVY/o8= -github.com/redis/go-redis/v9 v9.21.0 h1:FPBE4hhbAke+TLmcY3WkpbDffJEomdqPn3HYiqAtL9E= -github.com/redis/go-redis/v9 v9.21.0/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA= +github.com/redis/go-redis/v9 v9.22.0 h1:laDvpYXTJtZLloinw1fA5Kqd6HAEH2XKxOkG/PDq2F0= +github.com/redis/go-redis/v9 v9.22.0/go.mod h1:y2g0Wj8rQvuK0ELM+oxSudcLtC09JScs98I/X9gRWY4= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= diff --git a/pkg/gofr/container/mock_datasources.go b/pkg/gofr/container/mock_datasources.go index 1c3f0a8882..626232ec5b 100644 --- a/pkg/gofr/container/mock_datasources.go +++ b/pkg/gofr/container/mock_datasources.go @@ -1183,6 +1183,20 @@ func (mr *MockRedisMockRecorder) BLMove(ctx, source, destination, srcpos, destpo return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "BLMove", reflect.TypeOf((*MockRedis)(nil).BLMove), ctx, source, destination, srcpos, destpos, timeout) } +// BLMoveM mocks base method. +func (m *MockRedis) BLMoveM(ctx context.Context, source, destination, srcpos, destpos string, timeout time.Duration, args redis.LMoveMArgs) *redis.StringSliceCmd { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "BLMoveM", ctx, source, destination, srcpos, destpos, timeout, args) + ret0, _ := ret[0].(*redis.StringSliceCmd) + return ret0 +} + +// BLMoveM indicates an expected call of BLMoveM. +func (mr *MockRedisMockRecorder) BLMoveM(ctx, source, destination, srcpos, destpos, timeout, args any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "BLMoveM", reflect.TypeOf((*MockRedis)(nil).BLMoveM), ctx, source, destination, srcpos, destpos, timeout, args) +} + // BLPop mocks base method. func (m *MockRedis) BLPop(ctx context.Context, timeout time.Duration, keys ...string) *redis.StringSliceCmd { m.ctrl.T.Helper() @@ -2021,6 +2035,48 @@ func (mr *MockRedisMockRecorder) ClientPause(ctx, dur any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ClientPause", reflect.TypeOf((*MockRedis)(nil).ClientPause), ctx, dur) } +// ClientTracking mocks base method. +func (m *MockRedis) ClientTracking(ctx context.Context, on bool, opt *redis.ClientTrackingOptions) *redis.StatusCmd { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ClientTracking", ctx, on, opt) + ret0, _ := ret[0].(*redis.StatusCmd) + return ret0 +} + +// ClientTracking indicates an expected call of ClientTracking. +func (mr *MockRedisMockRecorder) ClientTracking(ctx, on, opt any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ClientTracking", reflect.TypeOf((*MockRedis)(nil).ClientTracking), ctx, on, opt) +} + +// ClientTrackingOff mocks base method. +func (m *MockRedis) ClientTrackingOff(ctx context.Context) *redis.StatusCmd { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ClientTrackingOff", ctx) + ret0, _ := ret[0].(*redis.StatusCmd) + return ret0 +} + +// ClientTrackingOff indicates an expected call of ClientTrackingOff. +func (mr *MockRedisMockRecorder) ClientTrackingOff(ctx any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ClientTrackingOff", reflect.TypeOf((*MockRedis)(nil).ClientTrackingOff), ctx) +} + +// ClientTrackingOn mocks base method. +func (m *MockRedis) ClientTrackingOn(ctx context.Context, opt *redis.ClientTrackingOptions) *redis.StatusCmd { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ClientTrackingOn", ctx, opt) + ret0, _ := ret[0].(*redis.StatusCmd) + return ret0 +} + +// ClientTrackingOn indicates an expected call of ClientTrackingOn. +func (mr *MockRedisMockRecorder) ClientTrackingOn(ctx, opt any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ClientTrackingOn", reflect.TypeOf((*MockRedis)(nil).ClientTrackingOn), ctx, opt) +} + // ClientUnblock mocks base method. func (m *MockRedis) ClientUnblock(ctx context.Context, id int64) *redis.IntCmd { m.ctrl.T.Helper() @@ -2982,6 +3038,20 @@ func (mr *MockRedisMockRecorder) FTAliasDel(ctx, alias any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FTAliasDel", reflect.TypeOf((*MockRedis)(nil).FTAliasDel), ctx, alias) } +// FTAliasList mocks base method. +func (m *MockRedis) FTAliasList(ctx context.Context, index string) *redis.StringSliceCmd { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "FTAliasList", ctx, index) + ret0, _ := ret[0].(*redis.StringSliceCmd) + return ret0 +} + +// FTAliasList indicates an expected call of FTAliasList. +func (mr *MockRedisMockRecorder) FTAliasList(ctx, index any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FTAliasList", reflect.TypeOf((*MockRedis)(nil).FTAliasList), ctx, index) +} + // FTAliasUpdate mocks base method. func (m *MockRedis) FTAliasUpdate(ctx context.Context, index, alias string) *redis.StatusCmd { m.ctrl.T.Helper() @@ -4037,6 +4107,72 @@ func (mr *MockRedisMockRecorder) HGetEXWithArgs(ctx, key, options any, fields .. return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HGetEXWithArgs", reflect.TypeOf((*MockRedis)(nil).HGetEXWithArgs), varargs...) } +// HImportDiscard mocks base method. +func (m *MockRedis) HImportDiscard(ctx context.Context, fieldsetName string) *redis.IntCmd { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "HImportDiscard", ctx, fieldsetName) + ret0, _ := ret[0].(*redis.IntCmd) + return ret0 +} + +// HImportDiscard indicates an expected call of HImportDiscard. +func (mr *MockRedisMockRecorder) HImportDiscard(ctx, fieldsetName any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HImportDiscard", reflect.TypeOf((*MockRedis)(nil).HImportDiscard), ctx, fieldsetName) +} + +// HImportDiscardAll mocks base method. +func (m *MockRedis) HImportDiscardAll(ctx context.Context) *redis.IntCmd { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "HImportDiscardAll", ctx) + ret0, _ := ret[0].(*redis.IntCmd) + return ret0 +} + +// HImportDiscardAll indicates an expected call of HImportDiscardAll. +func (mr *MockRedisMockRecorder) HImportDiscardAll(ctx any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HImportDiscardAll", reflect.TypeOf((*MockRedis)(nil).HImportDiscardAll), ctx) +} + +// HImportPrepare mocks base method. +func (m *MockRedis) HImportPrepare(ctx context.Context, fieldsetName string, fields ...string) *redis.StatusCmd { + m.ctrl.T.Helper() + varargs := []any{ctx, fieldsetName} + for _, a := range fields { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "HImportPrepare", varargs...) + ret0, _ := ret[0].(*redis.StatusCmd) + return ret0 +} + +// HImportPrepare indicates an expected call of HImportPrepare. +func (mr *MockRedisMockRecorder) HImportPrepare(ctx, fieldsetName any, fields ...any) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]any{ctx, fieldsetName}, fields...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HImportPrepare", reflect.TypeOf((*MockRedis)(nil).HImportPrepare), varargs...) +} + +// HImportSet mocks base method. +func (m *MockRedis) HImportSet(ctx context.Context, key, fieldsetName string, values ...any) *redis.StatusCmd { + m.ctrl.T.Helper() + varargs := []any{ctx, key, fieldsetName} + for _, a := range values { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "HImportSet", varargs...) + ret0, _ := ret[0].(*redis.StatusCmd) + return ret0 +} + +// HImportSet indicates an expected call of HImportSet. +func (mr *MockRedisMockRecorder) HImportSet(ctx, key, fieldsetName any, values ...any) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]any{ctx, key, fieldsetName}, values...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HImportSet", reflect.TypeOf((*MockRedis)(nil).HImportSet), varargs...) +} + // HIncrBy mocks base method. func (m *MockRedis) HIncrBy(ctx context.Context, key, field string, incr int64) *redis.IntCmd { m.ctrl.T.Helper() @@ -4541,6 +4677,25 @@ func (mr *MockRedisMockRecorder) Info(ctx any, section ...any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Info", reflect.TypeOf((*MockRedis)(nil).Info), varargs...) } +// InfoMap mocks base method. +func (m *MockRedis) InfoMap(ctx context.Context, section ...string) *redis.InfoCmd { + m.ctrl.T.Helper() + varargs := []any{ctx} + for _, a := range section { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "InfoMap", varargs...) + ret0, _ := ret[0].(*redis.InfoCmd) + return ret0 +} + +// InfoMap indicates an expected call of InfoMap. +func (mr *MockRedisMockRecorder) InfoMap(ctx any, section ...any) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]any{ctx}, section...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InfoMap", reflect.TypeOf((*MockRedis)(nil).InfoMap), varargs...) +} + // JSONArrAppend mocks base method. func (m *MockRedis) JSONArrAppend(ctx context.Context, key, path string, values ...any) *redis.IntSliceCmd { m.ctrl.T.Helper() @@ -5104,6 +5259,20 @@ func (mr *MockRedisMockRecorder) LMove(ctx, source, destination, srcpos, destpos return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "LMove", reflect.TypeOf((*MockRedis)(nil).LMove), ctx, source, destination, srcpos, destpos) } +// LMoveM mocks base method. +func (m *MockRedis) LMoveM(ctx context.Context, source, destination, srcpos, destpos string, args redis.LMoveMArgs) *redis.StringSliceCmd { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "LMoveM", ctx, source, destination, srcpos, destpos, args) + ret0, _ := ret[0].(*redis.StringSliceCmd) + return ret0 +} + +// LMoveM indicates an expected call of LMoveM. +func (mr *MockRedisMockRecorder) LMoveM(ctx, source, destination, srcpos, destpos, args any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "LMoveM", reflect.TypeOf((*MockRedis)(nil).LMoveM), ctx, source, destination, srcpos, destpos, args) +} + // LPop mocks base method. func (m *MockRedis) LPop(ctx context.Context, key string) *redis.StringCmd { m.ctrl.T.Helper() @@ -6016,6 +6185,25 @@ func (mr *MockRedisMockRecorder) SDiff(ctx any, keys ...any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SDiff", reflect.TypeOf((*MockRedis)(nil).SDiff), varargs...) } +// SDiffCard mocks base method. +func (m *MockRedis) SDiffCard(ctx context.Context, opts *redis.SDiffCardOptions, keys ...string) *redis.IntCmd { + m.ctrl.T.Helper() + varargs := []any{ctx, opts} + for _, a := range keys { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "SDiffCard", varargs...) + ret0, _ := ret[0].(*redis.IntCmd) + return ret0 +} + +// SDiffCard indicates an expected call of SDiffCard. +func (mr *MockRedisMockRecorder) SDiffCard(ctx, opts any, keys ...any) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]any{ctx, opts}, keys...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SDiffCard", reflect.TypeOf((*MockRedis)(nil).SDiffCard), varargs...) +} + // SDiffStore mocks base method. func (m *MockRedis) SDiffStore(ctx context.Context, destination string, keys ...string) *redis.IntCmd { m.ctrl.T.Helper() @@ -6289,6 +6477,25 @@ func (mr *MockRedisMockRecorder) SUnion(ctx any, keys ...any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SUnion", reflect.TypeOf((*MockRedis)(nil).SUnion), varargs...) } +// SUnionCard mocks base method. +func (m *MockRedis) SUnionCard(ctx context.Context, opts *redis.SUnionCardOptions, keys ...string) *redis.IntCmd { + m.ctrl.T.Helper() + varargs := []any{ctx, opts} + for _, a := range keys { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "SUnionCard", varargs...) + ret0, _ := ret[0].(*redis.IntCmd) + return ret0 +} + +// SUnionCard indicates an expected call of SUnionCard. +func (mr *MockRedisMockRecorder) SUnionCard(ctx, opts any, keys ...any) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]any{ctx, opts}, keys...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SUnionCard", reflect.TypeOf((*MockRedis)(nil).SUnionCard), varargs...) +} + // SUnionStore mocks base method. func (m *MockRedis) SUnionStore(ctx context.Context, destination string, keys ...string) *redis.IntCmd { m.ctrl.T.Helper() @@ -7389,6 +7596,62 @@ func (mr *MockRedisMockRecorder) TSMRevRangeWithArgs(ctx, fromTimestamp, toTimes return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "TSMRevRangeWithArgs", reflect.TypeOf((*MockRedis)(nil).TSMRevRangeWithArgs), ctx, fromTimestamp, toTimestamp, filterExpr, options) } +// TSNRange mocks base method. +func (m *MockRedis) TSNRange(ctx context.Context, keys []string, fromTimestamp, toTimestamp any) *redis.TSNRangePivotRowSliceCmd { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "TSNRange", ctx, keys, fromTimestamp, toTimestamp) + ret0, _ := ret[0].(*redis.TSNRangePivotRowSliceCmd) + return ret0 +} + +// TSNRange indicates an expected call of TSNRange. +func (mr *MockRedisMockRecorder) TSNRange(ctx, keys, fromTimestamp, toTimestamp any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "TSNRange", reflect.TypeOf((*MockRedis)(nil).TSNRange), ctx, keys, fromTimestamp, toTimestamp) +} + +// TSNRangeWithArgs mocks base method. +func (m *MockRedis) TSNRangeWithArgs(ctx context.Context, keys []string, fromTimestamp, toTimestamp any, options *redis.TSNRangeOptions) *redis.TSNRangePivotRowSliceCmd { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "TSNRangeWithArgs", ctx, keys, fromTimestamp, toTimestamp, options) + ret0, _ := ret[0].(*redis.TSNRangePivotRowSliceCmd) + return ret0 +} + +// TSNRangeWithArgs indicates an expected call of TSNRangeWithArgs. +func (mr *MockRedisMockRecorder) TSNRangeWithArgs(ctx, keys, fromTimestamp, toTimestamp, options any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "TSNRangeWithArgs", reflect.TypeOf((*MockRedis)(nil).TSNRangeWithArgs), ctx, keys, fromTimestamp, toTimestamp, options) +} + +// TSNRevRange mocks base method. +func (m *MockRedis) TSNRevRange(ctx context.Context, keys []string, fromTimestamp, toTimestamp any) *redis.TSNRangePivotRowSliceCmd { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "TSNRevRange", ctx, keys, fromTimestamp, toTimestamp) + ret0, _ := ret[0].(*redis.TSNRangePivotRowSliceCmd) + return ret0 +} + +// TSNRevRange indicates an expected call of TSNRevRange. +func (mr *MockRedisMockRecorder) TSNRevRange(ctx, keys, fromTimestamp, toTimestamp any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "TSNRevRange", reflect.TypeOf((*MockRedis)(nil).TSNRevRange), ctx, keys, fromTimestamp, toTimestamp) +} + +// TSNRevRangeWithArgs mocks base method. +func (m *MockRedis) TSNRevRangeWithArgs(ctx context.Context, keys []string, fromTimestamp, toTimestamp any, options *redis.TSNRevRangeOptions) *redis.TSNRangePivotRowSliceCmd { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "TSNRevRangeWithArgs", ctx, keys, fromTimestamp, toTimestamp, options) + ret0, _ := ret[0].(*redis.TSNRangePivotRowSliceCmd) + return ret0 +} + +// TSNRevRangeWithArgs indicates an expected call of TSNRevRangeWithArgs. +func (mr *MockRedisMockRecorder) TSNRevRangeWithArgs(ctx, keys, fromTimestamp, toTimestamp, options any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "TSNRevRangeWithArgs", reflect.TypeOf((*MockRedis)(nil).TSNRevRangeWithArgs), ctx, keys, fromTimestamp, toTimestamp, options) +} + // TSQueryIndex mocks base method. func (m *MockRedis) TSQueryIndex(ctx context.Context, filterExpr []string) *redis.StringSliceCmd { m.ctrl.T.Helper() @@ -7403,6 +7666,34 @@ func (mr *MockRedisMockRecorder) TSQueryIndex(ctx, filterExpr any) *gomock.Call return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "TSQueryIndex", reflect.TypeOf((*MockRedis)(nil).TSQueryIndex), ctx, filterExpr) } +// TSQueryLabelValues mocks base method. +func (m *MockRedis) TSQueryLabelValues(ctx context.Context, label string, filterExpr []string) *redis.StringSliceCmd { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "TSQueryLabelValues", ctx, label, filterExpr) + ret0, _ := ret[0].(*redis.StringSliceCmd) + return ret0 +} + +// TSQueryLabelValues indicates an expected call of TSQueryLabelValues. +func (mr *MockRedisMockRecorder) TSQueryLabelValues(ctx, label, filterExpr any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "TSQueryLabelValues", reflect.TypeOf((*MockRedis)(nil).TSQueryLabelValues), ctx, label, filterExpr) +} + +// TSQueryLabels mocks base method. +func (m *MockRedis) TSQueryLabels(ctx context.Context, filterExpr []string) *redis.StringSliceCmd { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "TSQueryLabels", ctx, filterExpr) + ret0, _ := ret[0].(*redis.StringSliceCmd) + return ret0 +} + +// TSQueryLabels indicates an expected call of TSQueryLabels. +func (mr *MockRedisMockRecorder) TSQueryLabels(ctx, filterExpr any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "TSQueryLabels", reflect.TypeOf((*MockRedis)(nil).TSQueryLabels), ctx, filterExpr) +} + // TSRange mocks base method. func (m *MockRedis) TSRange(ctx context.Context, key string, fromTimestamp, toTimestamp int) *redis.TSTimestampValueSliceCmd { m.ctrl.T.Helper() @@ -7431,6 +7722,34 @@ func (mr *MockRedisMockRecorder) TSRangeWithArgs(ctx, key, fromTimestamp, toTime return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "TSRangeWithArgs", reflect.TypeOf((*MockRedis)(nil).TSRangeWithArgs), ctx, key, fromTimestamp, toTimestamp, options) } +// TSRead mocks base method. +func (m *MockRedis) TSRead(ctx context.Context, key string, timestamp any) *redis.TSTimestampValueSliceCmd { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "TSRead", ctx, key, timestamp) + ret0, _ := ret[0].(*redis.TSTimestampValueSliceCmd) + return ret0 +} + +// TSRead indicates an expected call of TSRead. +func (mr *MockRedisMockRecorder) TSRead(ctx, key, timestamp any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "TSRead", reflect.TypeOf((*MockRedis)(nil).TSRead), ctx, key, timestamp) +} + +// TSReadWithArgs mocks base method. +func (m *MockRedis) TSReadWithArgs(ctx context.Context, key string, timestamp any, options *redis.TSReadOptions) *redis.TSTimestampValueSliceCmd { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "TSReadWithArgs", ctx, key, timestamp, options) + ret0, _ := ret[0].(*redis.TSTimestampValueSliceCmd) + return ret0 +} + +// TSReadWithArgs indicates an expected call of TSReadWithArgs. +func (mr *MockRedisMockRecorder) TSReadWithArgs(ctx, key, timestamp, options any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "TSReadWithArgs", reflect.TypeOf((*MockRedis)(nil).TSReadWithArgs), ctx, key, timestamp, options) +} + // TSRevRange mocks base method. func (m *MockRedis) TSRevRange(ctx context.Context, key string, fromTimestamp, toTimestamp int) *redis.TSTimestampValueSliceCmd { m.ctrl.T.Helper() From 6cd9ac4d69d4994faa181c4ec7175f9a4320d625 Mon Sep 17 00:00:00 2001 From: Umang Mundhra Date: Thu, 27 Aug 2026 11:00:46 +0530 Subject: [PATCH 15/20] chore(deps): bump golang 1.26-alpine -> 1.27-alpine in examples/http-server (#4098) Docker base image bump from Dependabot #4064 (docker group). Closes: #4064 Co-authored-by: claude-flow --- examples/http-server/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/http-server/Dockerfile b/examples/http-server/Dockerfile index ef54d07e5e..4612c14a5a 100644 --- a/examples/http-server/Dockerfile +++ b/examples/http-server/Dockerfile @@ -1,5 +1,5 @@ # Build stage -FROM golang:1.26-alpine AS build +FROM golang:1.27-alpine AS build RUN apk add --no-cache build-base WORKDIR /src From 7cbb41a385860b8a51db2e43fe155d79e85190ec Mon Sep 17 00:00:00 2001 From: Aryan Mehrotra Date: Fri, 28 Aug 2026 12:38:20 +0530 Subject: [PATCH 16/20] fix(http): characterize the request path and fix eleven defects it exposed (#3770) --- docs/advanced-guide/middlewares/page.md | 16 + .../advanced-guide/overriding-default/page.md | 7 + docs/references/context/page.md | 20 + pkg/gofr/handler.go | 10 +- pkg/gofr/handler_test.go | 544 +++++++ pkg/gofr/http/middleware/cors.go | 112 +- pkg/gofr/http/middleware/cors_test.go | 920 +++++++++++- pkg/gofr/http/middleware/logger_test.go | 1319 +++++++++++++++++ pkg/gofr/http/middleware/metrics_test.go | 1018 +++++++++++++ pkg/gofr/http/middleware/tracer.go | 11 +- pkg/gofr/http/middleware/tracer_test.go | 688 +++++++++ pkg/gofr/http/middleware/web_socket_test.go | 172 +++ pkg/gofr/http/request.go | 54 +- pkg/gofr/http/request_test.go | 741 ++++++++- pkg/gofr/http/responder_test.go | 934 ++++++++++++ pkg/gofr/logging/logger_test.go | 431 ++++++ 16 files changed, 6953 insertions(+), 44 deletions(-) diff --git a/docs/advanced-guide/middlewares/page.md b/docs/advanced-guide/middlewares/page.md index a676a6bd33..745c9c8455 100644 --- a/docs/advanced-guide/middlewares/page.md +++ b/docs/advanced-guide/middlewares/page.md @@ -31,6 +31,22 @@ The CORS middleware provides the following overridable configs: > Note: GoFr automatically interprets the registered route methods and based on that sets the value of `ACCESS_CONTROL_ALLOW_METHODS` +Configuration is read only under the exact names listed above (plus +`ACCESS_CONTROL_ALLOW_METHODS`) — a differently-spelled entry such as +`access_control_allow_origin` is not read at all. Two rules govern how the values are applied: + +- `ACCESS_CONTROL_ALLOW_HEADERS` **extends** the headers GoFr already allows rather than + replacing them, so the framework's own required headers cannot be dropped by adding one of + your own. +- `ACCESS_CONTROL_ALLOW_METHODS` **replaces** the value derived from your registered routes. +- `Access-Control-Allow-Origin` is always decided by the allow-list above and cannot be + overridden by another entry. This keeps a stray key from replacing an origin that was + correctly negotiated against `ACCESS_CONTROL_ALLOW_ORIGIN`. + +If you construct the middleware yourself with `middleware.CORS(map[string]string{...}, ...)`, +entries are matched by canonical HTTP header name — so the spelling you use there does not change +which header an entry controls — and any additional entry is sent as a response header as-is. + ## Adding Custom Middleware in GoFr diff --git a/docs/advanced-guide/overriding-default/page.md b/docs/advanced-guide/overriding-default/page.md index fe4314ddb5..53d85ab363 100644 --- a/docs/advanced-guide/overriding-default/page.md +++ b/docs/advanced-guide/overriding-default/page.md @@ -79,6 +79,13 @@ Response example: ] ``` +> **Return these response types by value, not as a pointer.** `response.Raw{...}` performs the raw +> response; `&response.Raw{...}` does not — it falls through to the ordinary `{"data": ...}` +> envelope and serializes the struct instead. The same applies to `response.File`, `response.XML`, +> `response.Template`, `response.Redirect`, `response.Stream` and `response.Response`, so a +> `&response.Redirect{...}` returns `200` with the URL inside the envelope rather than redirecting, +> and a `&response.File{...}` is base64-encoded into it rather than served as a file. + ### XML responses If you need to respond with XML without JSON encoding, return `response.XML`. It bypasses JSON encoding just like `response.File` or `response.Template` and writes the bytes directly to the client. The `ContentType` defaults to `application/xml` but can be overridden. diff --git a/docs/references/context/page.md b/docs/references/context/page.md index ca02e6ffd4..a903d1f658 100644 --- a/docs/references/context/page.md +++ b/docs/references/context/page.md @@ -63,6 +63,26 @@ ctx.Bind(&p) // the Bind() method will map the incoming request to variable p ``` +> **Pass a pointer.** `Bind` needs the address of your variable (`&p`) so it can write into it. +> For an HTTP request, passing a value (`ctx.Bind(p)`) returns an error rather than silently +> leaving your variable untouched. Always check the returned error: +> +> ```go +> if err := ctx.Bind(&p); err != nil { +> return nil, err +> } +> ``` + +The `Content-Type` header selects how the body is decoded. It is matched case-insensitively and +any parameters are ignored, so `application/json`, `Application/JSON` and +`application/json; charset=utf-8` are all treated the same. + +If a request carries a body whose `Content-Type` GoFr has no decoder for — `text/plain`, +`application/xml`, or no header at all — `Bind` is a **no-op**: it returns no error and leaves +your target zeroed, and the body is discarded. Check the `Content-Type` your clients actually +send. `fetch(url, {method: "POST", body: str})` with no headers sends `text/plain`, not JSON, so +a request that looks correct can bind nothing. + - `Binding multipart-form data / urlencoded form data ` - To bind multipart-form data or url-encoded form, we can use the Bind method similarly. The struct fields should be tagged appropriately to map the form fields to the struct fields. The supported content types are `multipart/form-data` and `application/x-www-form-urlencoded` diff --git a/pkg/gofr/handler.go b/pkg/gofr/handler.go index 68c1595d36..4de4221c4b 100644 --- a/pkg/gofr/handler.go +++ b/pkg/gofr/handler.go @@ -90,9 +90,15 @@ func (h handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { result, err = h.serveWithGoroutine(c, traceID, r) } - // Handle custom headers if 'result' is a 'Response'. - if resp, ok := result.(response.Response); ok { + // Handle custom headers if 'result' is a 'Response'. A pointer is handled + // equivalently to the value form, otherwise its Headers are silently dropped. + switch resp := result.(type) { + case response.Response: resp.SetCustomHeaders(w) + case *response.Response: + if resp != nil { + resp.SetCustomHeaders(w) + } } c.responder.Respond(result, err) diff --git a/pkg/gofr/handler_test.go b/pkg/gofr/handler_test.go index 839273baf4..be74044bd4 100644 --- a/pkg/gofr/handler_test.go +++ b/pkg/gofr/handler_test.go @@ -564,3 +564,547 @@ func TestIntegration_ServerTimeout(t *testing.T) { errorObj := errorResponse["error"].(map[string]any) assert.Equal(t, "request timed out", errorObj["message"]) } + +// --------------------------------------------------------------------------- +// Characterization suite for handler.ServeHTTP. +// +// Pins the handler-execution contract with exact status codes and exact body +// bytes for both execution paths (serveInline and serveWithGoroutine), for +// timeout, cancellation, panic recovery and the WebSocket bypass. The two paths +// are asserted against the SAME expectations wherever they should agree, so a +// refactor cannot let them drift. +// --------------------------------------------------------------------------- + +// charHandler builds a handler with a silent logger. +func charHandler(fn Handler, timeout time.Duration) handler { + return handler{ + function: fn, + container: &container.Container{Logger: logging.NewLogger(logging.FATAL)}, + requestTimeout: timeout, + } +} + +// charServe runs h against a fresh recorder for the given method. +func charServe(t *testing.T, h handler, method string) *httptest.ResponseRecorder { + t.Helper() + + w := httptest.NewRecorder() + h.ServeHTTP(w, httptest.NewRequestWithContext(t.Context(), method, "/", http.NoBody)) + + return w +} + +// TestHandler_Char_BothPathsAgree pins that serveInline (requestTimeout == 0) +// and serveWithGoroutine (requestTimeout > 0) produce byte-identical responses +// for every ordinary handler outcome. The timeout is generous so the deadline +// branch never fires. +func TestHandler_Char_BothPathsAgree(t *testing.T) { + cases := []struct { + name string + method string + fn Handler + wantStatus int + wantBody string + }{ + { + "nil-nil", http.MethodGet, + func(*Context) (any, error) { return nil, nil }, + http.StatusOK, "{}\n", + }, + { + "data", http.MethodGet, + func(*Context) (any, error) { return map[string]string{"m": "hi"}, nil }, + http.StatusOK, "{\"data\":{\"m\":\"hi\"}}\n", + }, + { + "post-created", http.MethodPost, + func(*Context) (any, error) { return "Created", nil }, + http.StatusCreated, "{\"data\":\"Created\"}\n", + }, + { + "delete-no-content", http.MethodDelete, + func(*Context) (any, error) { return nil, nil }, + http.StatusNoContent, "{}\n", + }, + { + "plain-error", http.MethodGet, + func(*Context) (any, error) { return nil, errTest }, + http.StatusInternalServerError, "{\"error\":{\"message\":\"some error\"}}\n", + }, + { + "status-coded-error", http.MethodGet, + func(*Context) (any, error) { return nil, gofrHTTP.ErrorEntityNotFound{Name: "id", Value: "3"} }, + http.StatusNotFound, "{\"error\":{\"message\":\"No entity found with id: 3\"}}\n", + }, + { + "data-and-error-partial", http.MethodGet, + func(*Context) (any, error) { return map[string]string{"p": "ok"}, errTest }, + http.StatusPartialContent, "{\"error\":{\"message\":\"some error\"},\"data\":{\"p\":\"ok\"}}\n", + }, + { + "invalid-route", http.MethodGet, + catchAllHandler, + http.StatusNotFound, "{\"error\":{\"message\":\"route not registered\"}}\n", + }, + { + "liveness", http.MethodGet, + liveHandler, + http.StatusOK, "{\"data\":{\"status\":\"UP\"}}\n", + }, + } + + paths := []struct { + name string + timeout time.Duration + }{ + {"inline", 0}, + {"goroutine", time.Minute}, + } + + for _, p := range paths { + for _, tc := range cases { + t.Run(p.name+"/"+tc.name, func(t *testing.T) { + w := charServe(t, charHandler(tc.fn, p.timeout), tc.method) + + assert.Equal(t, tc.wantStatus, w.Code, "status code") + assert.Equal(t, "application/json", w.Header().Get("Content-Type"), "Content-Type") + assert.Equal(t, tc.wantBody, w.Body.String(), "body bytes") + }) + } + } +} + +// TestHandler_Char_PanicRecovery pins panic recovery on BOTH paths: the client +// gets a 500 with the generic "Internal Server Error" envelope, and neither the +// panic value nor any stack frame leaks onto the wire. +func TestHandler_Char_PanicRecovery(t *testing.T) { + const secret = "SUPER-SECRET-PANIC-VALUE" + + errSecretPanic := errors.New(secret) //nolint:err113 // panic payload under test. + + panics := []struct { + name string + fn Handler + }{ + {"string-panic", func(*Context) (any, error) { panic(secret) }}, + {"error-panic", func(*Context) (any, error) { panic(errSecretPanic) }}, + {"runtime-panic", func(*Context) (any, error) { + s := []int{1} + idx := len(s) + 1 + + return s[idx], nil // index out of range + }}, + } + + timeouts := []struct { + name string + timeout time.Duration + }{ + {"inline", 0}, + {"goroutine", time.Minute}, + } + + for _, to := range timeouts { + for _, p := range panics { + t.Run(to.name+"/"+p.name, func(t *testing.T) { + w := charServe(t, charHandler(p.fn, to.timeout), http.MethodGet) + + assert.Equal(t, http.StatusInternalServerError, w.Code) + assert.Equal(t, "application/json", w.Header().Get("Content-Type")) + //nolint:testifylint // exact bytes are the contract. + assert.Equal(t, "{\"error\":{\"message\":\"Internal Server Error\"}}\n", w.Body.String()) + + // Nothing about the panic reaches the client. + assert.NotContains(t, w.Body.String(), secret) + assert.NotContains(t, w.Body.String(), "goroutine") + assert.NotContains(t, w.Body.String(), "handler_test.go") + }) + } + } +} + +// TestHandler_Char_PanicAfterPartialResult pins that a handler which panics +// AFTER computing a value still yields the bare 500 envelope — the partial +// result is discarded, not returned as 206. +func TestHandler_Char_PanicAfterPartialResult(t *testing.T) { + for _, timeout := range []time.Duration{0, time.Minute} { + w := charServe(t, charHandler(func(*Context) (any, error) { + defer panic("late") + + return map[string]string{"leaked": "value"}, nil + }, timeout), http.MethodGet) + + assert.Equal(t, http.StatusInternalServerError, w.Code) + //nolint:testifylint // exact bytes are the contract. + assert.Equal(t, "{\"error\":{\"message\":\"Internal Server Error\"}}\n", w.Body.String()) + } +} + +// TestHandler_Char_ServerTimeout pins the server-side request timeout: the +// deadline branch of serveWithGoroutine fires while the handler is still +// running and the client gets exactly 408 with the timeout envelope. Any result +// the handler eventually produces is dropped. +func TestHandler_Char_ServerTimeout(t *testing.T) { + release := make(chan struct{}) + defer close(release) + + h := charHandler(func(*Context) (any, error) { + <-release + + return "too late", nil + }, 10*time.Millisecond) + + w := charServe(t, h, http.MethodGet) + + assert.Equal(t, http.StatusRequestTimeout, w.Code) + assert.Equal(t, "application/json", w.Header().Get("Content-Type")) + //nolint:testifylint // exact bytes are the contract. + assert.Equal(t, "{\"error\":{\"message\":\"request timed out\"}}\n", w.Body.String()) +} + +// TestHandler_Char_InlineDeadlineExceeded pins the inline path's post-hoc +// deadline check: the handler runs to completion (Go cannot kill a goroutine), +// but because the inherited context expired the result is dropped and the +// client sees 408 rather than the handler's value. +func TestHandler_Char_InlineDeadlineExceeded(t *testing.T) { + ctx, cancel := context.WithTimeout(t.Context(), time.Millisecond) + defer cancel() + + var ran bool + + h := charHandler(func(*Context) (any, error) { + ran = true + + time.Sleep(20 * time.Millisecond) + + return "computed anyway", nil + }, 0) + + w := httptest.NewRecorder() + h.ServeHTTP(w, httptest.NewRequestWithContext(ctx, http.MethodGet, "/", http.NoBody)) + + assert.True(t, ran, "the handler still runs to completion on the inline path") + assert.Equal(t, http.StatusRequestTimeout, w.Code) + //nolint:testifylint // exact bytes are the contract. + assert.Equal(t, "{\"error\":{\"message\":\"request timed out\"}}\n", w.Body.String()) +} + +// TestHandler_Char_ClientCanceled pins client cancellation on both paths: the +// non-standard 499 with the "client closed request" envelope. +// +// The goroutine path needs the handler to stay in flight for the assertion to +// mean anything. serveWithGoroutine selects over c.Context.Done() and the +// handler's done channel, and a handler that returns immediately makes BOTH +// ready on an already-canceled context — Go then picks uniformly at random, so +// asserting 499 would be a coin flip that fails on loaded CI about half the +// time. Blocking the handler until the test releases it leaves Done() as the +// only ready case, which is the path this test is about. +func TestHandler_Char_ClientCanceled(t *testing.T) { + for _, tc := range []struct { + name string + timeout time.Duration + }{ + {"inline", 0}, + {"goroutine", time.Minute}, + } { + t.Run(tc.name, func(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + cancel() + + // Buffered and closed on cleanup, so the handler goroutine on the + // goroutine path always drains rather than leaking into later tests. + release := make(chan struct{}) + + t.Cleanup(func() { close(release) }) + + h := charHandler(func(*Context) (any, error) { + // The inline path runs this on the calling goroutine, where the + // context is already canceled and nothing would release it. + if tc.timeout > 0 { + <-release + } + + return "ignored", nil + }, tc.timeout) + + w := httptest.NewRecorder() + h.ServeHTTP(w, httptest.NewRequestWithContext(ctx, http.MethodGet, "/", http.NoBody)) + + assert.Equal(t, gofrHTTP.StatusClientClosedRequest, w.Code) + assert.Equal(t, 499, w.Code) + //nolint:testifylint // exact bytes are the contract. + assert.Equal(t, "{\"error\":{\"message\":\"client closed request\"}}\n", w.Body.String()) + }) + } +} + +// TestHandler_Char_PanicBeatsCancellation pins the precedence on the inline +// path: a handler that panics on an already-canceled context reports the panic +// (500), because the cancellation remap is skipped when panicked is set. +func TestHandler_Char_PanicBeatsCancellation(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + cancel() + + h := charHandler(func(*Context) (any, error) { panic("boom") }, 0) + + w := httptest.NewRecorder() + h.ServeHTTP(w, httptest.NewRequestWithContext(ctx, http.MethodGet, "/", http.NoBody)) + + assert.Equal(t, http.StatusInternalServerError, w.Code) + //nolint:testifylint // exact bytes are the contract. + assert.Equal(t, "{\"error\":{\"message\":\"Internal Server Error\"}}\n", w.Body.String()) +} + +// TestHandler_Char_ContextDeadlinePropagation pins which context the user +// handler actually receives on each path. +func TestHandler_Char_ContextDeadlinePropagation(t *testing.T) { + t.Run("no-timeout-has-no-deadline", func(t *testing.T) { + var hasDeadline bool + + charServe(t, charHandler(func(c *Context) (any, error) { + _, hasDeadline = c.Deadline() + + return nil, nil + }, 0), http.MethodGet) + + assert.False(t, hasDeadline) + }) + + t.Run("timeout-sets-deadline", func(t *testing.T) { + var ( + hasDeadline bool + remaining time.Duration + ) + + charServe(t, charHandler(func(c *Context) (any, error) { + var dl time.Time + + dl, hasDeadline = c.Deadline() + remaining = time.Until(dl) + + return nil, nil + }, time.Minute), http.MethodGet) + + assert.True(t, hasDeadline) + // Normalized: only the ballpark is asserted, never the wall clock. + assert.Positive(t, remaining) + assert.LessOrEqual(t, remaining, time.Minute) + }) +} + +// TestHandler_Char_WebSocketBypassesTimeout pins that a WebSocket upgrade +// request ignores requestTimeout entirely: the handler's context carries no +// deadline and a handler slower than the configured timeout still returns its +// own result rather than a 408. +func TestHandler_Char_WebSocketBypassesTimeout(t *testing.T) { + var hasDeadline bool + + h := charHandler(func(c *Context) (any, error) { + _, hasDeadline = c.Deadline() + + time.Sleep(30 * time.Millisecond) + + return "ws-ok", nil + }, 5*time.Millisecond) + + r := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/ws", http.NoBody) + r.Header.Set("Connection", "Upgrade") + r.Header.Set("Upgrade", "websocket") + + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + + assert.False(t, hasDeadline, "a WebSocket request must not inherit the request timeout") + assert.Equal(t, http.StatusOK, w.Code) + //nolint:testifylint // exact bytes are the contract. + assert.Equal(t, "{\"data\":\"ws-ok\"}\n", w.Body.String()) +} + +// TestHandler_Char_WebSocketStillWritesJSONEnvelope pins a sharp edge: despite +// the "do not respond with HTTP headers since this is a WebSocket request" +// comment in handleWebSocketUpgrade, the normal JSON envelope IS still written +// for an upgrade request whose handler returns without hijacking the +// connection. handleWebSocketUpgrade is a no-op in both of its branches. +func TestHandler_Char_WebSocketStillWritesJSONEnvelope(t *testing.T) { + h := charHandler(func(*Context) (any, error) { return "ws", nil }, 0) + + r := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/ws", http.NoBody) + r.Header.Set("Connection", "Upgrade") + r.Header.Set("Upgrade", "websocket") + + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, "application/json", w.Header().Get("Content-Type")) + //nolint:testifylint // exact bytes are the contract. + assert.Equal(t, "{\"data\":\"ws\"}\n", w.Body.String()) +} + +// TestHandler_Char_ResponseCustomHeaders pins that ServeHTTP applies +// response.Response.Headers to the writer (Respond itself never does) and that +// the headers do not appear in the JSON body. +func TestHandler_Char_ResponseCustomHeaders(t *testing.T) { + for _, timeout := range []time.Duration{0, time.Minute} { + w := charServe(t, charHandler(func(*Context) (any, error) { + return response.Response{ + Data: map[string]string{"m": "hi"}, + Metadata: map[string]any{"page": 1}, + Headers: map[string]string{"X-One": "1", "x-two": "2"}, + }, nil + }, timeout), http.MethodGet) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, "1", w.Header().Get("X-One")) + // Header keys are canonicalized by net/http. + assert.Equal(t, "2", w.Header().Get("X-Two")) + assert.Equal(t, "application/json", w.Header().Get("Content-Type")) + //nolint:testifylint // exact bytes are the contract. + assert.Equal(t, "{\"metadata\":{\"page\":1},\"data\":{\"m\":\"hi\"}}\n", w.Body.String()) + } +} + +// TestHandler_Char_ResponseCustomHeadersCanOverrideContentType pins that a +// handler-supplied Content-Type wins, because ServeHTTP sets the custom headers +// before Respond decides whether to default it. +func TestHandler_Char_ResponseCustomHeadersCanOverrideContentType(t *testing.T) { + w := charServe(t, charHandler(func(*Context) (any, error) { + return response.Response{ + Data: "x", + Headers: map[string]string{"Content-Type": "application/vnd.custom+json"}, + }, nil + }, 0), http.MethodGet) + + assert.Equal(t, "application/vnd.custom+json", w.Header().Get("Content-Type")) + //nolint:testifylint // exact bytes are the contract. + assert.Equal(t, "{\"data\":\"x\"}\n", w.Body.String()) +} + +// TestHandler_Char_ResponsePointerHeadersApplied pins the fix: returning a +// POINTER to response.Response now applies the custom headers and takes the +// Response envelope path, exactly like the value form. Previously the type +// assertion in ServeHTTP was by value, so the headers were silently dropped and +// the struct was serialized as ordinary data. WIRE-FORMAT CHANGE. +func TestHandler_Char_ResponsePointerHeadersApplied(t *testing.T) { + w := charServe(t, charHandler(func(*Context) (any, error) { + return &response.Response{Data: "x", Headers: map[string]string{"X-One": "1"}}, nil + }, 0), http.MethodGet) + + assert.Equal(t, "1", w.Header().Get("X-One"), "custom headers apply to a *Response too") + // The BODY is still double-enveloped, because the pointer is not + // dereferenced: only SetCustomHeaders is reached through the pointer. + //nolint:testifylint // exact bytes are the contract. + assert.Equal(t, "{\"data\":{\"data\":\"x\"}}\n", w.Body.String()) +} + +// TestHandler_Char_SpecialResponseTypes pins that the special response types +// survive the handler path unchanged. +func TestHandler_Char_SpecialResponseTypes(t *testing.T) { + t.Run("file", func(t *testing.T) { + w := charServe(t, charHandler(func(*Context) (any, error) { + return response.File{Content: []byte("abc"), ContentType: "text/csv"}, nil + }, 0), http.MethodGet) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, "text/csv", w.Header().Get("Content-Type")) + assert.Equal(t, "abc", w.Body.String()) + }) + + t.Run("redirect-get", func(t *testing.T) { + w := charServe(t, charHandler(func(*Context) (any, error) { + return response.Redirect{URL: "/elsewhere"}, nil + }, 0), http.MethodGet) + + assert.Equal(t, http.StatusFound, w.Code) + assert.Equal(t, "/elsewhere", w.Header().Get("Location")) + assert.Empty(t, w.Body.String()) + }) + + t.Run("redirect-post", func(t *testing.T) { + w := charServe(t, charHandler(func(*Context) (any, error) { + return response.Redirect{URL: "/elsewhere"}, nil + }, time.Minute), http.MethodPost) + + assert.Equal(t, http.StatusSeeOther, w.Code) + assert.Equal(t, "/elsewhere", w.Header().Get("Location")) + }) + + t.Run("raw", func(t *testing.T) { + w := charServe(t, charHandler(func(*Context) (any, error) { + return response.Raw{Data: map[string]string{"k": "v"}}, nil + }, 0), http.MethodGet) + + //nolint:testifylint // exact bytes are the contract. + assert.Equal(t, "{\"k\":\"v\"}\n", w.Body.String()) + }) +} + +// TestHandler_Char_ErrorLogging pins WHICH log level each error class is +// emitted at by logError — the level is part of the operational contract even +// though it never reaches the client. +func TestHandler_Char_ErrorLogging(t *testing.T) { + tests := []struct { + name string + err error + wantLevel string + // ERROR (and above) is written to stderr; everything else to stdout. + onStderr bool + }{ + {"plain-error-is-ERROR", errTest, "ERROR", true}, + {"entity-not-found-is-INFO", gofrHTTP.ErrorEntityNotFound{}, "INFO", false}, + {"already-exists-is-WARN", gofrHTTP.ErrorEntityAlreadyExist{}, "WARN", false}, + {"invalid-route-is-INFO", gofrHTTP.ErrorInvalidRoute{}, "INFO", false}, + {"panic-recovery-is-ERROR", gofrHTTP.ErrorPanicRecovery{}, "ERROR", true}, + {"too-many-requests-is-WARN", gofrHTTP.ErrorTooManyRequests{}, "WARN", false}, + {"client-closed-is-DEBUG", gofrHTTP.ErrorClientClosedRequest{}, "DEBUG", false}, + {"request-timeout-is-INFO", gofrHTTP.ErrorRequestTimeout{}, "INFO", false}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + serve := func() { + h := handler{ + function: func(*Context) (any, error) { return nil, tc.err }, + container: &container.Container{Logger: logging.NewLogger(logging.DEBUG)}, + } + + h.ServeHTTP(httptest.NewRecorder(), + httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", http.NoBody)) + } + + logs := testutil.StdoutOutputForFunc(serve) + if tc.onStderr { + logs = testutil.StderrOutputForFunc(serve) + } + + assert.Contains(t, logs, tc.wantLevel) + assert.Contains(t, logs, tc.err.Error()) + }) + } +} + +// TestHandler_Char_NoErrorNoLog pins that a successful handler logs nothing +// from logError. +func TestHandler_Char_NoErrorNoLog(t *testing.T) { + logs := testutil.StdoutOutputForFunc(func() { + h := handler{ + function: func(*Context) (any, error) { return "ok", nil }, + container: &container.Container{Logger: logging.NewLogger(logging.DEBUG)}, + } + + h.ServeHTTP(httptest.NewRecorder(), + httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", http.NoBody)) + }) + + assert.Empty(t, logs) +} + +// TestErrorLogEntry_Char_PrettyPrint pins the exact bytes of the pretty-printed +// error log line, ANSI color escapes included. +func TestErrorLogEntry_Char_PrettyPrint(t *testing.T) { + var buf strings.Builder + + (&ErrorLogEntry{TraceID: "abc123", Error: "went wrong"}).PrettyPrint(&buf) + + assert.Equal(t, "\u001B[38;5;8mabc123 \u001B[38;5;202mwent wrong \n", buf.String()) +} diff --git a/pkg/gofr/http/middleware/cors.go b/pkg/gofr/http/middleware/cors.go index 0c544f7ea3..71eb9acd1b 100644 --- a/pkg/gofr/http/middleware/cors.go +++ b/pkg/gofr/http/middleware/cors.go @@ -8,9 +8,9 @@ import ( const ( allowedHeaders = "Authorization, Content-Type, x-requested-with, origin, true-client-ip, X-Correlation-ID" + headerAccessControlAllowOrigin = "Access-Control-Allow-Origin" headerAccessControlAllowMethods = "Access-Control-Allow-Methods" headerAccessControlAllowHeaders = "Access-Control-Allow-Headers" - headerAccessControlAllowOrigin = "Access-Control-Allow-Origin" ) // CORS is a middleware that adds CORS (Cross-Origin Resource Sharing) headers to the response. @@ -19,11 +19,12 @@ const ( // the middleware dynamically matches the request's Origin header and responds // with the matched origin, adding a Vary: Origin header for correct caching. func CORS(middlewareConfigs map[string]string, routes *[]string) func(inner http.Handler) http.Handler { - allowedOrigins := parseOrigins(middlewareConfigs[headerAccessControlAllowOrigin]) + configs := canonicalizeConfig(middlewareConfigs) + allowedOrigins := parseOrigins(configs[headerAccessControlAllowOrigin]) return func(inner http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - setMiddlewareHeaders(middlewareConfigs, *routes, w, r.Header.Get("Origin"), allowedOrigins) + setMiddlewareHeaders(configs, *routes, w, r.Header.Get("Origin"), allowedOrigins) if r.Method == http.MethodOptions { w.WriteHeader(http.StatusOK) @@ -38,8 +39,6 @@ func CORS(middlewareConfigs map[string]string, routes *[]string) func(inner http func setMiddlewareHeaders(middlewareConfigs map[string]string, routes []string, w http.ResponseWriter, origin string, allowedOrigins map[string]bool, ) { - routes = append(routes, "OPTIONS") - // Handle Access-Control-Allow-Origin separately for dynamic matching. if allowedOrigins["*"] { w.Header().Set(headerAccessControlAllowOrigin, "*") @@ -48,30 +47,97 @@ func setMiddlewareHeaders(middlewareConfigs map[string]string, routes []string, w.Header().Add("Vary", "Origin") } - // Set default headers (excluding origin, handled above) - defaultHeaders := map[string]string{ - headerAccessControlAllowMethods: strings.Join(routes, ", "), - headerAccessControlAllowHeaders: allowedHeaders, + // The keys arrive canonicalized from canonicalizeConfig, so a differently-cased + // spelling cannot reach a header it was never checked against — replacing the + // origin negotiated above, or overwriting the default allow-list instead of + // extending it. + var customMethods, customHeaders string + + for key, value := range middlewareConfigs { + switch key { + case headerAccessControlAllowOrigin: + // Always negotiated against the configured allow-list above; never + // overridable from here. + case headerAccessControlAllowMethods: + customMethods = value + case headerAccessControlAllowHeaders: + customHeaders = value + default: + w.Header().Set(key, value) + } } - for header, defaultValue := range defaultHeaders { - if customValue, ok := middlewareConfigs[header]; ok && customValue != "" { - if header == headerAccessControlAllowHeaders { - w.Header().Set(header, defaultValue+", "+customValue) - } else { - w.Header().Set(header, customValue) - } - } else { - w.Header().Set(header, defaultValue) - } + // A configured method list replaces the derived one; configured headers + // EXTEND the defaults rather than replacing them, so the framework's own + // required headers (Authorization, Content-Type, X-Correlation-ID, ...) + // cannot be dropped by adding one of your own. + if customMethods == "" { + customMethods = joinAllowedMethods(routes) + } + + allowHeaderValue := allowedHeaders + if customHeaders != "" { + allowHeaderValue = allowedHeaders + ", " + customHeaders + } + + w.Header().Set(headerAccessControlAllowMethods, customMethods) + w.Header().Set(headerAccessControlAllowHeaders, allowHeaderValue) +} + +// joinAllowedMethods renders the Access-Control-Allow-Methods value: the +// registered routes plus OPTIONS. +// +// It deliberately does not append to routes. That slice shares its backing +// array with the caller's (the router's RegisteredRoutes), so appending in +// place writes "OPTIONS" over the caller's next element whenever cap > len. +func joinAllowedMethods(routes []string) string { + if len(routes) == 0 { + return http.MethodOptions } - // Handle additional custom headers (not part of defaultHeaders or origin) - for header, customValue := range middlewareConfigs { - if _, ok := defaultHeaders[header]; !ok && header != headerAccessControlAllowOrigin { - w.Header().Set(header, customValue) + return strings.Join(routes, ", ") + ", " + http.MethodOptions +} + +// canonicalizeConfig folds the caller's configuration onto canonical header keys, once, at setup. +// +// Two things depend on this. The allow-list read by parseOrigins used a raw literal lookup while the +// per-request walk classified by canonical name, so the two disagreed: a caller who spelled the key +// "access-control-allow-origin" had it dropped by the classifier AND missed by parseOrigins, which +// then fell through to its wildcard default — turning a config that restricted the origin into one +// that echoed "*" to an unlisted origin. CORS is exported, so callers do build this map themselves. +// +// The other is determinism. Header names are case-insensitive, so two spellings are one header, and +// a map has no order — resolving the collision during the per-request walk meant the winner was +// whichever key map iteration reached last, and a different value could be sent on different +// requests within a single process. Precedence is explicit here instead: an exactly-canonical +// spelling always wins, and among the rest the lexicographically smallest key does. +func canonicalizeConfig(cfg map[string]string) map[string]string { + canonical := make(map[string]string, len(cfg)) + + // Track which raw key won each canonical slot, so precedence does not depend on iteration order. + winner := make(map[string]string, len(cfg)) + + for key, value := range cfg { + ck := http.CanonicalHeaderKey(key) + + if prev, ok := winner[ck]; ok && !better(key, prev, ck) { + continue } + + winner[ck] = key + canonical[ck] = value } + + return canonical +} + +// better reports whether raw key a should beat b for the canonical slot ck. +func better(a, b, ck string) bool { + if (a == ck) != (b == ck) { + return a == ck + } + + return a < b } // parseOrigins splits a comma-separated origin string into a set. diff --git a/pkg/gofr/http/middleware/cors_test.go b/pkg/gofr/http/middleware/cors_test.go index b8b0b059b3..d8b3055c42 100644 --- a/pkg/gofr/http/middleware/cors_test.go +++ b/pkg/gofr/http/middleware/cors_test.go @@ -3,10 +3,13 @@ package middleware import ( "net/http" "net/http/httptest" + "sort" "strconv" + "strings" "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) type MockHandlerForCORS struct { @@ -134,7 +137,7 @@ func setMiddlewareHeadersTestCases() []struct { allowedOrigins: map[string]bool{"*": true}, expectedHeaders: map[string]string{ "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Headers": allowedHeaders, + "Access-Control-Allow-Headers": corsCharDefaultAllowHeaders, "Access-Control-Allow-Methods": "GET, OPTIONS", }, }, @@ -145,7 +148,7 @@ func setMiddlewareHeadersTestCases() []struct { allowedOrigins: map[string]bool{"*": true}, expectedHeaders: map[string]string{ "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Headers": allowedHeaders + ", clientid", + "Access-Control-Allow-Headers": corsCharDefaultAllowHeaders + ", clientid", "Access-Control-Allow-Methods": "POST, PUT, OPTIONS", }, }, @@ -161,7 +164,7 @@ func setMiddlewareHeadersTestCases() []struct { expectedHeaders: map[string]string{ "Access-Control-Max-Age": strconv.Itoa(600), "Access-Control-Allow-Origin": "https://example.com", - "Access-Control-Allow-Headers": allowedHeaders, + "Access-Control-Allow-Headers": corsCharDefaultAllowHeaders, "Access-Control-Allow-Methods": "OPTIONS", "Vary": "Origin", }, @@ -175,7 +178,7 @@ func setMiddlewareHeadersTestCases() []struct { allowedOrigins: map[string]bool{"*": true}, expectedHeaders: map[string]string{ "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Headers": allowedHeaders, + "Access-Control-Allow-Headers": corsCharDefaultAllowHeaders, "Access-Control-Allow-Methods": "GET, POST, PUT, PATCH, DELETE, OPTIONS", }, }, @@ -189,7 +192,7 @@ func setMiddlewareHeadersTestCases() []struct { allowedOrigins: map[string]bool{"https://a.com": true, "https://b.com": true}, expectedHeaders: map[string]string{ "Access-Control-Allow-Origin": "https://b.com", - "Access-Control-Allow-Headers": allowedHeaders, + "Access-Control-Allow-Headers": corsCharDefaultAllowHeaders, "Access-Control-Allow-Methods": "GET, OPTIONS", "Vary": "Origin", }, @@ -204,7 +207,7 @@ func setMiddlewareHeadersTestCases() []struct { allowedOrigins: map[string]bool{"https://a.com": true}, expectedHeaders: map[string]string{ "Access-Control-Allow-Origin": "", - "Access-Control-Allow-Headers": allowedHeaders, + "Access-Control-Allow-Headers": corsCharDefaultAllowHeaders, "Access-Control-Allow-Methods": "GET, OPTIONS", }, }, @@ -263,3 +266,908 @@ func TestParseOrigins(t *testing.T) { }) } } + +// --------------------------------------------------------------------------- +// Characterization suite: pins the EXACT observable output of the CORS +// middleware. Every helper/type/const below is prefixed with `corsChar` and +// every test with `Test_CORSContract` to stay collision-free. +// +// These tests describe CURRENT behavior, including behavior that looks like a +// latent bug. They must be updated only when a behavior change is intentional. +// --------------------------------------------------------------------------- + +// corsCharDefaultAllowHeaders is the literal Access-Control-Allow-Headers +// value GoFr puts on the wire. It is spelled out here on purpose rather than +// referencing the production `allowedHeaders` constant: a characterization +// test that reuses the constant under test would silently follow any edit to +// it. Test_CORSContract_DefaultAllowHeadersLiteral asserts the two agree, so +// changing the production spelling (including its casing or its comma-space +// separators) fails loudly here. +const corsCharDefaultAllowHeaders = "Authorization, Content-Type, x-requested-with, " + + "origin, true-client-ip, X-Correlation-ID" + +// Test_CORSContract_DefaultAllowHeadersLiteral pins the exact bytes of the +// default Access-Control-Allow-Headers value. +func Test_CORSContract_DefaultAllowHeadersLiteral(t *testing.T) { + assert.Equal(t, corsCharDefaultAllowHeaders, allowedHeaders) +} + +const ( + corsCharBody = "Sample Response" + corsCharOriginA = "https://a.com" + corsCharOriginB = "https://b.com" + corsCharOriginEvil = "https://evil.com" + corsCharKeyOrigin = "Access-Control-Allow-Origin" + corsCharKeyMethods = "Access-Control-Allow-Methods" + corsCharKeyHeaders = "Access-Control-Allow-Headers" + corsCharAllowHeadersLine = corsCharKeyHeaders + ": " + corsCharDefaultAllowHeaders + corsCharVaryLine = "Vary: Origin" +) + +// corsCharSpyHandler records whether the inner handler was reached. +type corsCharSpyHandler struct { + called int +} + +func (h *corsCharSpyHandler) ServeHTTP(w http.ResponseWriter, _ *http.Request) { + h.called++ + + w.WriteHeader(http.StatusFound) + _, _ = w.Write([]byte(corsCharBody)) +} + +// corsCharHeaderLines renders a whole http.Header into a deterministic, sorted +// slice of "Key: v1, v2" lines so a full snapshot can be compared exactly. +func corsCharHeaderLines(h http.Header) []string { + lines := make([]string, 0, len(h)) + for k, v := range h { + lines = append(lines, k+": "+strings.Join(v, ", ")) + } + + sort.Strings(lines) + + return lines +} + +func corsCharSorted(in []string) []string { + out := make([]string, len(in)) + copy(out, in) + sort.Strings(out) + + return out +} + +func corsCharMethodsLine(v string) string { return corsCharKeyMethods + ": " + v } + +func corsCharOriginLine(v string) string { return corsCharKeyOrigin + ": " + v } + +// corsCharRun drives the middleware once and returns the recorder plus the spy. +func corsCharRun(t *testing.T, cfg map[string]string, routes *[]string, + method, origin string, +) (*httptest.ResponseRecorder, *corsCharSpyHandler) { + t.Helper() + + spy := &corsCharSpyHandler{} + handler := CORS(cfg, routes)(spy) + + req := httptest.NewRequestWithContext(t.Context(), method, "/hello", http.NoBody) + if origin != "" { + req.Header.Set("Origin", origin) + } + + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + return w, spy +} + +type corsCharCase struct { + name string + config map[string]string + method string + origin string + routes []string + expLines []string + expCode int + expBody string + expInner int +} + +func corsCharBaselineCases() []corsCharCase { + twoRoutes := []string{http.MethodGet, http.MethodPost} + baseMethods := corsCharMethodsLine("GET, POST, OPTIONS") + + return []corsCharCase{ + { + name: "nil config GET no origin", config: nil, method: http.MethodGet, + routes: twoRoutes, + expLines: []string{corsCharAllowHeadersLine, baseMethods, corsCharOriginLine("*")}, + expCode: http.StatusFound, expBody: corsCharBody, expInner: 1, + }, + { + name: "empty config POST no origin", config: map[string]string{}, method: http.MethodPost, + routes: twoRoutes, + expLines: []string{corsCharAllowHeadersLine, baseMethods, corsCharOriginLine("*")}, + expCode: http.StatusFound, expBody: corsCharBody, expInner: 1, + }, + { + name: "empty config OPTIONS short circuits", config: map[string]string{}, method: http.MethodOptions, + routes: twoRoutes, + expLines: []string{corsCharAllowHeadersLine, baseMethods, corsCharOriginLine("*")}, + expCode: http.StatusOK, expBody: "", expInner: 0, + }, + { + name: "empty origin config value falls back to wildcard", + config: map[string]string{corsCharKeyOrigin: ""}, method: http.MethodGet, origin: corsCharOriginA, + routes: twoRoutes, + expLines: []string{corsCharAllowHeadersLine, baseMethods, corsCharOriginLine("*")}, + expCode: http.StatusFound, expBody: corsCharBody, expInner: 1, + }, + { + name: "explicit wildcard never adds Vary", + config: map[string]string{corsCharKeyOrigin: "*"}, method: http.MethodGet, origin: corsCharOriginA, + routes: twoRoutes, + expLines: []string{corsCharAllowHeadersLine, baseMethods, corsCharOriginLine("*")}, + expCode: http.StatusFound, expBody: corsCharBody, expInner: 1, + }, + { + name: "wildcard OPTIONS with origin", + config: map[string]string{corsCharKeyOrigin: "*"}, method: http.MethodOptions, origin: corsCharOriginA, + routes: twoRoutes, + expLines: []string{corsCharAllowHeadersLine, baseMethods, corsCharOriginLine("*")}, + expCode: http.StatusOK, expBody: "", expInner: 0, + }, + } +} + +func corsCharOriginMatchingCases() []corsCharCase { + twoRoutes := []string{http.MethodGet, http.MethodPost} + baseMethods := corsCharMethodsLine("GET, POST, OPTIONS") + + return []corsCharCase{ + { + name: "single origin matched adds Vary", + config: map[string]string{corsCharKeyOrigin: corsCharOriginA}, method: http.MethodGet, origin: corsCharOriginA, + routes: twoRoutes, + expLines: []string{corsCharAllowHeadersLine, baseMethods, corsCharOriginLine(corsCharOriginA), corsCharVaryLine}, + expCode: http.StatusFound, expBody: corsCharBody, expInner: 1, + }, + { + name: "single origin not matched drops origin and Vary", + config: map[string]string{corsCharKeyOrigin: corsCharOriginA}, method: http.MethodGet, origin: corsCharOriginEvil, + routes: twoRoutes, + expLines: []string{corsCharAllowHeadersLine, baseMethods}, + expCode: http.StatusFound, expBody: corsCharBody, expInner: 1, + }, + { + name: "single origin with no Origin request header drops origin", + config: map[string]string{corsCharKeyOrigin: corsCharOriginA}, method: http.MethodGet, + routes: twoRoutes, + expLines: []string{corsCharAllowHeadersLine, baseMethods}, + expCode: http.StatusFound, expBody: corsCharBody, expInner: 1, + }, + { + name: "single origin OPTIONS not matched still short circuits", + config: map[string]string{corsCharKeyOrigin: corsCharOriginA}, method: http.MethodOptions, origin: corsCharOriginEvil, + routes: twoRoutes, + expLines: []string{corsCharAllowHeadersLine, baseMethods}, + expCode: http.StatusOK, expBody: "", expInner: 0, + }, + { + name: "comma list without spaces", + config: map[string]string{corsCharKeyOrigin: corsCharOriginA + "," + corsCharOriginB}, + method: http.MethodGet, origin: corsCharOriginB, routes: twoRoutes, + expLines: []string{corsCharAllowHeadersLine, baseMethods, corsCharOriginLine(corsCharOriginB), corsCharVaryLine}, + expCode: http.StatusFound, expBody: corsCharBody, expInner: 1, + }, + { + name: "comma list with spaces", + config: map[string]string{corsCharKeyOrigin: corsCharOriginA + " , " + corsCharOriginB}, + method: http.MethodGet, origin: corsCharOriginA, routes: twoRoutes, + expLines: []string{corsCharAllowHeadersLine, baseMethods, corsCharOriginLine(corsCharOriginA), corsCharVaryLine}, + expCode: http.StatusFound, expBody: corsCharBody, expInner: 1, + }, + { + name: "comma list with empty entry is skipped", + config: map[string]string{corsCharKeyOrigin: corsCharOriginA + ", ," + corsCharOriginB}, + method: http.MethodGet, origin: corsCharOriginB, routes: twoRoutes, + expLines: []string{corsCharAllowHeadersLine, baseMethods, corsCharOriginLine(corsCharOriginB), corsCharVaryLine}, + expCode: http.StatusFound, expBody: corsCharBody, expInner: 1, + }, + { + name: "only separators degrades to wildcard", + config: map[string]string{corsCharKeyOrigin: ", , ,"}, method: http.MethodGet, origin: corsCharOriginEvil, + routes: twoRoutes, + expLines: []string{corsCharAllowHeadersLine, baseMethods, corsCharOriginLine("*")}, + expCode: http.StatusFound, expBody: corsCharBody, expInner: 1, + }, + { + name: "origin match is exact and untrimmed", + config: map[string]string{corsCharKeyOrigin: corsCharOriginA}, method: http.MethodGet, origin: corsCharOriginA + " ", + routes: twoRoutes, + expLines: []string{corsCharAllowHeadersLine, baseMethods}, + expCode: http.StatusFound, expBody: corsCharBody, expInner: 1, + }, + { + name: "origin match is case sensitive", + config: map[string]string{corsCharKeyOrigin: corsCharOriginA}, method: http.MethodGet, origin: "HTTPS://A.COM", + routes: twoRoutes, + expLines: []string{corsCharAllowHeadersLine, baseMethods}, + expCode: http.StatusFound, expBody: corsCharBody, expInner: 1, + }, + } +} + +func corsCharCustomHeaderCases() []corsCharCase { + twoRoutes := []string{http.MethodGet, http.MethodPost} + baseMethods := corsCharMethodsLine("GET, POST, OPTIONS") + + return []corsCharCase{ + { + name: "custom allow-headers is concatenated onto the defaults", + config: map[string]string{corsCharKeyHeaders: "clientid"}, method: http.MethodGet, routes: twoRoutes, + expLines: []string{corsCharAllowHeadersLine + ", clientid", baseMethods, corsCharOriginLine("*")}, + expCode: http.StatusFound, expBody: corsCharBody, expInner: 1, + }, + { + name: "empty custom allow-headers keeps the defaults", + config: map[string]string{corsCharKeyHeaders: ""}, method: http.MethodGet, routes: twoRoutes, + expLines: []string{corsCharAllowHeadersLine, baseMethods, corsCharOriginLine("*")}, + expCode: http.StatusFound, expBody: corsCharBody, expInner: 1, + }, + { + name: "custom allow-methods fully replaces routes derived value", + config: map[string]string{corsCharKeyMethods: "GET, PUT"}, method: http.MethodGet, routes: twoRoutes, + expLines: []string{corsCharAllowHeadersLine, corsCharMethodsLine("GET, PUT"), corsCharOriginLine("*")}, + expCode: http.StatusFound, expBody: corsCharBody, expInner: 1, + }, + { + name: "empty custom allow-methods keeps routes derived value", + config: map[string]string{corsCharKeyMethods: ""}, method: http.MethodGet, routes: twoRoutes, + expLines: []string{corsCharAllowHeadersLine, baseMethods, corsCharOriginLine("*")}, + expCode: http.StatusFound, expBody: corsCharBody, expInner: 1, + }, + { + name: "credentials max-age and expose-headers pass through", + config: map[string]string{ + "Access-Control-Allow-Credentials": "true", + "Access-Control-Max-Age": "600", + "Access-Control-Expose-Headers": "X-Foo, X-Bar", + }, + method: http.MethodGet, routes: twoRoutes, + expLines: []string{ + "Access-Control-Allow-Credentials: true", + corsCharAllowHeadersLine, + baseMethods, + corsCharOriginLine("*"), + "Access-Control-Expose-Headers: X-Foo, X-Bar", + "Access-Control-Max-Age: 600", + }, + expCode: http.StatusFound, expBody: corsCharBody, expInner: 1, + }, + { + name: "empty valued custom header is still emitted with an empty value", + config: map[string]string{"Access-Control-Max-Age": ""}, method: http.MethodGet, routes: twoRoutes, + expLines: []string{ + corsCharAllowHeadersLine, baseMethods, corsCharOriginLine("*"), "Access-Control-Max-Age: ", + }, + expCode: http.StatusFound, expBody: corsCharBody, expInner: 1, + }, + { + name: "everything combined", + config: map[string]string{ + corsCharKeyOrigin: corsCharOriginA + ", " + corsCharOriginB, + corsCharKeyHeaders: "clientid", + corsCharKeyMethods: "GET, DELETE", + "Access-Control-Allow-Credentials": "true", + "Access-Control-Max-Age": "86400", + }, + method: http.MethodOptions, origin: corsCharOriginB, routes: twoRoutes, + expLines: []string{ + "Access-Control-Allow-Credentials: true", + corsCharAllowHeadersLine + ", clientid", + corsCharMethodsLine("GET, DELETE"), + corsCharOriginLine(corsCharOriginB), + "Access-Control-Max-Age: 86400", + corsCharVaryLine, + }, + expCode: http.StatusOK, expBody: "", expInner: 0, + }, + } +} + +// corsCharGarbageKeyCases pins what happens for config keys that are not the +// canonical, exactly-cased header names the implementation compares against. +func corsCharGarbageKeyCases() []corsCharCase { + twoRoutes := []string{http.MethodGet, http.MethodPost} + baseMethods := corsCharMethodsLine("GET, POST, OPTIONS") + + return []corsCharCase{ + { + name: "arbitrary key is blindly set and canonicalized", + config: map[string]string{"x-garbage": "boom"}, method: http.MethodGet, routes: twoRoutes, + expLines: []string{corsCharAllowHeadersLine, baseMethods, corsCharOriginLine("*"), "X-Garbage: boom"}, + expCode: http.StatusFound, expBody: corsCharBody, expInner: 1, + }, + { + name: "key with a space is not canonicalizable and is stored verbatim", + config: map[string]string{"Bad Key": "v"}, method: http.MethodGet, routes: twoRoutes, + expLines: []string{corsCharAllowHeadersLine, "Bad Key: v", baseMethods, corsCharOriginLine("*")}, + expCode: http.StatusFound, expBody: corsCharBody, expInner: 1, + }, + { + name: "underscore is a token char so only the first letter is upper cased", + config: map[string]string{"x_under_score": "v"}, method: http.MethodGet, routes: twoRoutes, + expLines: []string{corsCharAllowHeadersLine, baseMethods, corsCharOriginLine("*"), "X_under_score: v"}, + expCode: http.StatusFound, expBody: corsCharBody, expInner: 1, + }, + { + // A differently-cased allow-headers key EXTENDS the defaults, exactly + // like the canonical spelling. Matching the raw key previously made + // it miss the concat branch and replace the list instead, silently + // dropping Authorization, Content-Type and X-Correlation-ID. + name: "lower cased allow-headers key extends the defaults, like the canonical key", + config: map[string]string{"access-control-allow-headers": "only-this"}, method: http.MethodGet, routes: twoRoutes, + expLines: []string{ + corsCharKeyHeaders + ": " + corsCharDefaultAllowHeaders + ", only-this", + baseMethods, corsCharOriginLine("*"), + }, + expCode: http.StatusFound, expBody: corsCharBody, expInner: 1, + }, + { + name: "upper cased allow-methods key overwrites the routes derived value", + config: map[string]string{"ACCESS-CONTROL-ALLOW-METHODS": "TRACE"}, method: http.MethodGet, routes: twoRoutes, + expLines: []string{corsCharAllowHeadersLine, corsCharMethodsLine("TRACE"), corsCharOriginLine("*")}, + expCode: http.StatusFound, expBody: corsCharBody, expInner: 1, + }, + { + name: "mixed case max-age key is canonicalized", + config: map[string]string{"ACCESS-CONTROL-MAX-AGE": "60"}, method: http.MethodGet, routes: twoRoutes, + expLines: []string{ + corsCharAllowHeadersLine, baseMethods, corsCharOriginLine("*"), "Access-Control-Max-Age: 60", + }, + expCode: http.StatusFound, expBody: corsCharBody, expInner: 1, + }, + // The two cases below guard the origin-override fix. A config key is + // compared on its canonical header form, so a differently-cased + // spelling can no longer reach Access-Control-Allow-Origin through the + // custom-header loop and replace the negotiated value. + { + name: "lower cased origin key configures the allow-list and emits nothing for an unlisted origin", + config: map[string]string{"access-control-allow-origin": corsCharOriginEvil}, method: http.MethodGet, routes: twoRoutes, + // The key is folded to its canonical spelling, so this IS the + // allow-list — it restricts origins to corsCharOriginEvil. The + // request carries no Origin, which is not on that list, so nothing + // is negotiated and the custom loop must not emit one of its own. + // + // This case previously expected "*". That was the bug: the + // allow-list was read with a raw literal lookup and missed the key + // entirely, so parseOrigins fell back to its wildcard default and a + // config that restricted the origin echoed "*" instead. + expLines: []string{corsCharAllowHeadersLine, baseMethods}, + expCode: http.StatusFound, expBody: corsCharBody, expInner: 1, + }, + { + name: "lower cased origin key cannot overwrite a properly negotiated origin", + config: map[string]string{ + corsCharKeyOrigin: corsCharOriginA, + "access-control-allow-origin": corsCharOriginEvil, + }, + method: http.MethodGet, origin: corsCharOriginA, routes: twoRoutes, + expLines: []string{ + corsCharAllowHeadersLine, baseMethods, corsCharOriginLine(corsCharOriginA), corsCharVaryLine, + }, + expCode: http.StatusFound, expBody: corsCharBody, expInner: 1, + }, + } +} + +func corsCharAllCases() []corsCharCase { + cases := corsCharBaselineCases() + cases = append(cases, corsCharOriginMatchingCases()...) + cases = append(cases, corsCharCustomHeaderCases()...) + cases = append(cases, corsCharGarbageKeyCases()...) + + return cases +} + +func Test_CORSContract_ResponseSnapshot(t *testing.T) { + cases := corsCharAllCases() + + for i := range cases { + tc := &cases[i] + + t.Run(tc.name, func(t *testing.T) { + routes := tc.routes + w, spy := corsCharRun(t, tc.config, &routes, tc.method, tc.origin) + + assert.Equal(t, corsCharSorted(tc.expLines), corsCharHeaderLines(w.Header())) + assert.Equal(t, tc.expCode, w.Code) + assert.Equal(t, tc.expBody, w.Body.String()) + assert.Equal(t, tc.expInner, spy.called) + }) + } +} + +func Test_CORSContract_AllowMethodsJoin(t *testing.T) { + cases := []struct { + name string + routes []string + exp string + }{ + {name: "nil slice", routes: nil, exp: "OPTIONS"}, + {name: "empty slice", routes: []string{}, exp: "OPTIONS"}, + {name: "single element", routes: []string{http.MethodGet}, exp: "GET, OPTIONS"}, + {name: "two elements", routes: []string{http.MethodGet, http.MethodPut}, exp: "GET, PUT, OPTIONS"}, + {name: "element already containing commas", routes: []string{"GET,POST"}, exp: "GET,POST, OPTIONS"}, + {name: "element already containing OPTIONS is duplicated", routes: []string{"OPTIONS"}, exp: "OPTIONS, OPTIONS"}, + {name: "empty string element", routes: []string{""}, exp: ", OPTIONS"}, + {name: "whitespace preserved", routes: []string{" GET "}, exp: " GET , OPTIONS"}, + } + + for i := range cases { + tc := &cases[i] + + t.Run(tc.name, func(t *testing.T) { + routes := tc.routes + w, _ := corsCharRun(t, nil, &routes, http.MethodGet, "") + + assert.Equal(t, corsCharSorted([]string{ + corsCharAllowHeadersLine, corsCharMethodsLine(tc.exp), corsCharOriginLine("*"), + }), corsCharHeaderLines(w.Header())) + }) + } +} + +// Test_CORSContract_RoutesReadAtRequestTime pins that the routes slice is +// dereferenced per request, so routes registered after the middleware was +// constructed do show up in Access-Control-Allow-Methods. +func Test_CORSContract_RoutesReadAtRequestTime(t *testing.T) { + routes := []string{http.MethodGet} + handler := CORS(nil, &routes)(&corsCharSpyHandler{}) + + first := httptest.NewRecorder() + handler.ServeHTTP(first, httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/hello", http.NoBody)) + require.Equal(t, "GET, OPTIONS", first.Header().Get(corsCharKeyMethods)) + + routes = []string{http.MethodGet, http.MethodPost} + + second := httptest.NewRecorder() + handler.ServeHTTP(second, httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/hello", http.NoBody)) + assert.Equal(t, "GET, POST, OPTIONS", second.Header().Get(corsCharKeyMethods)) + + // Replacing the whole slice through the same variable is also picked up. + routes = []string{http.MethodDelete} + + third := httptest.NewRecorder() + handler.ServeHTTP(third, httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/hello", http.NoBody)) + assert.Equal(t, "DELETE, OPTIONS", third.Header().Get(corsCharKeyMethods)) +} + +// Test_CORSContract_RoutesBackingArrayAliasing guards against writing through +// the caller's route slice. The header used to be built with +// `routes = append(routes, "OPTIONS")` on a copy of the dereferenced slice +// header, so whenever cap > len the append stored "OPTIONS" into the CALLER's +// backing array at index len. That was invisible through the caller's own slice +// (its length is unchanged) but visible through any longer alias — here, +// SENTINEL-1 would be clobbered. +func Test_CORSContract_RoutesBackingArrayAliasing(t *testing.T) { + backing := []string{http.MethodGet, "SENTINEL-1", "SENTINEL-2", "SENTINEL-3"} + routes := backing[:1] + require.Equal(t, 4, cap(routes), "precondition: cap must exceed len for aliasing to be observable") + + w, _ := corsCharRun(t, nil, &routes, http.MethodGet, "") + require.Equal(t, "GET, OPTIONS", w.Header().Get(corsCharKeyMethods)) + + // The middleware must not write through the shared backing array. It used to + // build the header with append(routes, "OPTIONS"), which stores into the + // caller's array whenever cap > len — silently replacing the element after + // the caller's length. Here that would clobber SENTINEL-1. + assert.Equal(t, []string{http.MethodGet}, routes, "caller's slice must be untouched") + assert.Equal(t, []string{http.MethodGet, "SENTINEL-1", "SENTINEL-2", "SENTINEL-3"}, backing, + "the caller's backing array must be left intact") + + // A caller-side append still behaves normally afterwards, and the next + // request reflects the newly registered route. + routes = append(routes, http.MethodPost) + assert.Equal(t, []string{http.MethodGet, http.MethodPost}, routes) + + w2, _ := corsCharRun(t, nil, &routes, http.MethodGet, "") + assert.Equal(t, "GET, POST, OPTIONS", w2.Header().Get(corsCharKeyMethods)) + assert.Equal(t, "SENTINEL-2", backing[2], "second request must not clobber the array either") +} + +// Test_CORSContract_ParseOriginsEvaluatedOnce pins that the allowed-origin set +// is computed ONCE at construction time, so mutating the config map afterwards +// does not change origin matching, even though the header-setting loop does +// read the map on every request. +func Test_CORSContract_ParseOriginsEvaluatedOnce(t *testing.T) { + cfg := map[string]string{corsCharKeyOrigin: corsCharOriginA} + routes := []string{http.MethodGet} + handler := CORS(cfg, &routes)(&corsCharSpyHandler{}) + + cfg[corsCharKeyOrigin] = corsCharOriginB + + // The stale set still matches the ORIGINAL origin. + stale := httptest.NewRecorder() + reqA := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/hello", http.NoBody) + reqA.Header.Set("Origin", corsCharOriginA) + handler.ServeHTTP(stale, reqA) + + assert.Equal(t, corsCharSorted([]string{ + corsCharAllowHeadersLine, corsCharMethodsLine("GET, OPTIONS"), + corsCharOriginLine(corsCharOriginA), corsCharVaryLine, + }), corsCharHeaderLines(stale.Header())) + + // The newly configured origin is NOT honored. + fresh := httptest.NewRecorder() + reqB := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/hello", http.NoBody) + reqB.Header.Set("Origin", corsCharOriginB) + handler.ServeHTTP(fresh, reqB) + + assert.Equal(t, corsCharSorted([]string{ + corsCharAllowHeadersLine, corsCharMethodsLine("GET, OPTIONS"), + }), corsCharHeaderLines(fresh.Header())) +} + +// Test_CORSContract_ConfigMutationIsIgnoredAfterConstruction pins the other +// half. The configuration is folded onto canonical header keys once, inside +// CORS, so a caller that mutates the map afterwards changes nothing. +// +// The custom-header loop used to read the map on every request, which made +// post-construction keys appear immediately. That was never a feature worth +// keeping: the map is read from every serving goroutine, so a caller mutating +// it while requests are in flight is a data race, and the origin allow-list had +// already been resolved once at construction — so the two halves of the same +// config disagreed about when it was read. +func Test_CORSContract_ConfigMutationIsIgnoredAfterConstruction(t *testing.T) { + cfg := map[string]string{} + routes := []string{http.MethodGet} + handler := CORS(cfg, &routes)(&corsCharSpyHandler{}) + + before := httptest.NewRecorder() + handler.ServeHTTP(before, httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/hello", http.NoBody)) + require.Equal(t, corsCharSorted([]string{ + corsCharAllowHeadersLine, corsCharMethodsLine("GET, OPTIONS"), corsCharOriginLine("*"), + }), corsCharHeaderLines(before.Header())) + + cfg["Access-Control-Max-Age"] = "42" + cfg[corsCharKeyHeaders] = "clientid" + cfg[corsCharKeyMethods] = "GET, PATCH" + + after := httptest.NewRecorder() + handler.ServeHTTP(after, httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/hello", http.NoBody)) + + assert.Equal(t, corsCharSorted([]string{ + corsCharAllowHeadersLine, corsCharMethodsLine("GET, OPTIONS"), corsCharOriginLine("*"), + }), corsCharHeaderLines(after.Header()), "the response must be identical to the one before the mutation") +} + +// Test_CORSContract_CustomHeaderLoopIsOrderIndependent pins that Go's random +// map iteration order over the custom-header loop cannot change the final +// header set. +func Test_CORSContract_CustomHeaderLoopIsOrderIndependent(t *testing.T) { + cfg := map[string]string{ + corsCharKeyOrigin: corsCharOriginA, + corsCharKeyHeaders: "clientid", + corsCharKeyMethods: "GET, PATCH", + "Access-Control-Allow-Credentials": "true", + "Access-Control-Expose-Headers": "X-A, X-B", + "Access-Control-Max-Age": "600", + "X-Custom-One": "1", + "X-Custom-Two": "2", + "x-custom-three": "3", + } + + expected := corsCharSorted([]string{ + "Access-Control-Allow-Credentials: true", + corsCharAllowHeadersLine + ", clientid", + corsCharMethodsLine("GET, PATCH"), + corsCharOriginLine(corsCharOriginA), + "Access-Control-Expose-Headers: X-A, X-B", + "Access-Control-Max-Age: 600", + "X-Custom-One: 1", + "X-Custom-Three: 3", + "X-Custom-Two: 2", + corsCharVaryLine, + }) + + for range 50 { + routes := []string{http.MethodGet} + w, _ := corsCharRun(t, cfg, &routes, http.MethodGet, corsCharOriginA) + + require.Equal(t, expected, corsCharHeaderLines(w.Header())) + } +} + +// Test_CORSContract_VaryIsAddedNotSet pins that Vary is added once per request +// and that repeated requests on fresh recorders never accumulate values. +func Test_CORSContract_VaryIsAddedNotSet(t *testing.T) { + cfg := map[string]string{corsCharKeyOrigin: corsCharOriginA} + routes := []string{http.MethodGet} + handler := CORS(cfg, &routes)(&corsCharSpyHandler{}) + + for range 3 { + w := httptest.NewRecorder() + req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/hello", http.NoBody) + req.Header.Set("Origin", corsCharOriginA) + handler.ServeHTTP(w, req) + + assert.Equal(t, []string{"Origin"}, w.Header().Values("Vary")) + } +} + +// Test_CORSContract_VaryAccumulatesOnSharedResponseWriter pins that the +// middleware uses Add (not Set) for Vary, so a pre-existing Vary value is +// preserved and appended to. +func Test_CORSContract_VaryAccumulatesOnSharedResponseWriter(t *testing.T) { + cfg := map[string]string{corsCharKeyOrigin: corsCharOriginA} + routes := []string{http.MethodGet} + handler := CORS(cfg, &routes)(&corsCharSpyHandler{}) + + w := httptest.NewRecorder() + w.Header().Add("Vary", "Accept-Encoding") + + req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/hello", http.NoBody) + req.Header.Set("Origin", corsCharOriginA) + handler.ServeHTTP(w, req) + + assert.Equal(t, []string{"Accept-Encoding", "Origin"}, w.Header().Values("Vary")) + assert.Equal(t, "Vary: Accept-Encoding, Origin", corsCharHeaderLines(w.Header())[3]) +} + +func Test_CORSContract_ParseOriginsExact(t *testing.T) { + cases := []struct { + name string + in string + exp map[string]bool + }{ + {name: "empty", in: "", exp: map[string]bool{"*": true}}, + {name: "single space", in: " ", exp: map[string]bool{"*": true}}, + {name: "single comma", in: ",", exp: map[string]bool{"*": true}}, + {name: "wildcard with spaces", in: " * ", exp: map[string]bool{"*": true}}, + {name: "wildcard mixed with explicit origins", in: "*," + corsCharOriginA, + exp: map[string]bool{"*": true, corsCharOriginA: true}}, + {name: "duplicates collapse", in: corsCharOriginA + "," + corsCharOriginA, + exp: map[string]bool{corsCharOriginA: true}}, + {name: "tabs and newlines are trimmed", in: "\t" + corsCharOriginA + "\n", + exp: map[string]bool{corsCharOriginA: true}}, + {name: "empty entries dropped", in: corsCharOriginA + ", ," + corsCharOriginB, + exp: map[string]bool{corsCharOriginA: true, corsCharOriginB: true}}, + {name: "semicolons are not separators", in: corsCharOriginA + ";" + corsCharOriginB, + exp: map[string]bool{corsCharOriginA + ";" + corsCharOriginB: true}}, + {name: "trailing comma", in: corsCharOriginA + ",", exp: map[string]bool{corsCharOriginA: true}}, + } + + for i := range cases { + tc := &cases[i] + + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.exp, parseOrigins(tc.in)) + }) + } +} + +// Test_CORSContract_WildcardBeatsExplicitMatch pins that a "*" entry anywhere in +// the list short-circuits dynamic matching and suppresses Vary entirely. +func Test_CORSContract_WildcardBeatsExplicitMatch(t *testing.T) { + routes := []string{http.MethodGet} + cfg := map[string]string{corsCharKeyOrigin: corsCharOriginA + ",*"} + + w, _ := corsCharRun(t, cfg, &routes, http.MethodGet, corsCharOriginA) + + assert.Equal(t, corsCharSorted([]string{ + corsCharAllowHeadersLine, corsCharMethodsLine("GET, OPTIONS"), corsCharOriginLine("*"), + }), corsCharHeaderLines(w.Header())) + assert.Empty(t, w.Header().Values("Vary")) +} + +// Test_CORSContract_OptionsSkipsInnerHandlerForEveryConfig pins that OPTIONS +// short-circuits regardless of configuration or origin match. +func Test_CORSContract_OptionsSkipsInnerHandlerForEveryConfig(t *testing.T) { + configs := []map[string]string{ + nil, + {}, + {corsCharKeyOrigin: "*"}, + {corsCharKeyOrigin: corsCharOriginA}, + {corsCharKeyOrigin: corsCharOriginA, corsCharKeyMethods: "GET"}, + {"x-garbage": "boom"}, + } + + origins := []string{"", corsCharOriginA, corsCharOriginEvil} + + for i, cfg := range configs { + for _, origin := range origins { + routes := []string{http.MethodGet} + w, spy := corsCharRun(t, cfg, &routes, http.MethodOptions, origin) + + assert.Equal(t, http.StatusOK, w.Code, "config %d origin %q", i, origin) + assert.Empty(t, w.Body.String(), "config %d origin %q", i, origin) + assert.Equal(t, 0, spy.called, "config %d origin %q", i, origin) + } + } +} + +// Test_CORSContract_NonOptionsMethodsAlwaysReachInner pins that every +// non-OPTIONS method falls through to the inner handler unchanged. +func Test_CORSContract_NonOptionsMethodsAlwaysReachInner(t *testing.T) { + methods := []string{ + http.MethodGet, http.MethodPost, http.MethodPut, http.MethodPatch, + http.MethodDelete, http.MethodHead, http.MethodTrace, http.MethodConnect, + } + + for _, m := range methods { + routes := []string{http.MethodGet} + w, spy := corsCharRun(t, map[string]string{corsCharKeyOrigin: "*"}, &routes, m, corsCharOriginA) + + assert.Equal(t, http.StatusFound, w.Code, "method %s", m) + assert.Equal(t, corsCharBody, w.Body.String(), "method %s", m) + assert.Equal(t, 1, spy.called, "method %s", m) + assert.Equal(t, corsCharSorted([]string{ + corsCharAllowHeadersLine, corsCharMethodsLine("GET, OPTIONS"), corsCharOriginLine("*"), + }), corsCharHeaderLines(w.Header()), "method %s", m) + } +} + +// Test_CORSContract_SetMiddlewareHeadersDirectSnapshot pins the unexported +// helper directly, including the case where the passed allowedOrigins set +// disagrees with the config map (which the exported CORS wrapper cannot do). +func Test_CORSContract_SetMiddlewareHeadersDirectSnapshot(t *testing.T) { + cases := []struct { + name string + config map[string]string + routes []string + origin string + allowed map[string]bool + expLines []string + }{ + { + name: "nil allowed origins set emits no origin header", config: map[string]string{}, + routes: []string{http.MethodGet}, origin: corsCharOriginA, allowed: nil, + expLines: []string{corsCharAllowHeadersLine, corsCharMethodsLine("GET, OPTIONS")}, + }, + { + name: "empty allowed origins set emits no origin header", config: map[string]string{}, + routes: []string{http.MethodGet}, origin: corsCharOriginA, allowed: map[string]bool{}, + expLines: []string{corsCharAllowHeadersLine, corsCharMethodsLine("GET, OPTIONS")}, + }, + { + name: "false valued entry does not match", config: map[string]string{}, + routes: []string{http.MethodGet}, origin: corsCharOriginA, + allowed: map[string]bool{corsCharOriginA: false}, + expLines: []string{corsCharAllowHeadersLine, corsCharMethodsLine("GET, OPTIONS")}, + }, + { + name: "empty string origin can be allowed and is echoed as an empty header", + config: map[string]string{}, routes: []string{http.MethodGet}, origin: "", + allowed: map[string]bool{"": true}, + expLines: []string{ + corsCharAllowHeadersLine, corsCharMethodsLine("GET, OPTIONS"), + corsCharKeyOrigin + ": ", corsCharVaryLine, + }, + }, + { + name: "allowed set overrides what the config map says", config: map[string]string{corsCharKeyOrigin: corsCharOriginA}, + routes: []string{http.MethodGet}, origin: corsCharOriginEvil, + allowed: map[string]bool{corsCharOriginEvil: true}, + expLines: []string{ + corsCharAllowHeadersLine, corsCharMethodsLine("GET, OPTIONS"), + corsCharOriginLine(corsCharOriginEvil), corsCharVaryLine, + }, + }, + } + + for i := range cases { + tc := &cases[i] + + t.Run(tc.name, func(t *testing.T) { + w := httptest.NewRecorder() + setMiddlewareHeaders(tc.config, tc.routes, w, tc.origin, tc.allowed) + + assert.Equal(t, corsCharSorted(tc.expLines), corsCharHeaderLines(w.Header())) + }) + } +} + +// TestCORS_OriginAllowListMatchedByCanonicalKey is the regression test for a config that restricted +// the origin being turned into one that echoed a wildcard. +// +// setMiddlewareHeaders classifies config entries by canonical header name, but the allow-list was +// read with a raw literal lookup. A caller spelling the key "access-control-allow-origin" — CORS is +// exported, so callers do build this map — had it dropped by the classifier and missed by +// parseOrigins, which then fell back to its wildcard default and sent "*" to an unlisted origin. +func TestCORS_OriginAllowListMatchedByCanonicalKey(t *testing.T) { + spellings := []string{ + "Access-Control-Allow-Origin", + "access-control-allow-origin", + "ACCESS-CONTROL-ALLOW-ORIGIN", + "Access-control-allow-origin", + } + + for _, spelling := range spellings { + t.Run(spelling, func(t *testing.T) { + routes := []string{http.MethodGet} + handler := CORS(map[string]string{spelling: "https://trusted.com"}, &routes)( + http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) + + unlisted := httptest.NewRecorder() + req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", http.NoBody) + req.Header.Set("Origin", "https://evil.com") + handler.ServeHTTP(unlisted, req) + + assert.Empty(t, unlisted.Header().Get(headerAccessControlAllowOrigin), + "an unlisted origin must not be granted access under any spelling of the config key") + + listed := httptest.NewRecorder() + req = httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", http.NoBody) + req.Header.Set("Origin", "https://trusted.com") + handler.ServeHTTP(listed, req) + + assert.Equal(t, "https://trusted.com", listed.Header().Get(headerAccessControlAllowOrigin)) + assert.Equal(t, "Origin", listed.Header().Get("Vary"), + "a negotiated origin must not be cached across origins") + }) + } +} + +// TestCORS_DuplicateSpellingsResolveDeterministically pins the precedence rule. Two spellings of one +// header are one header, and the map they arrive in has no order, so resolving the collision during +// the per-request walk let map iteration decide the winner — a different value could be sent on +// different requests within a single process. +func TestCORS_DuplicateSpellingsResolveDeterministically(t *testing.T) { + routes := []string{http.MethodGet} + handler := CORS(map[string]string{ + "Access-Control-Allow-Headers": "X-Canonical", + "access-control-allow-headers": "x-lower", + "ACCESS-CONTROL-ALLOW-HEADERS": "X-UPPER", + }, &routes)(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) + + // Many requests, because the bug this guards was probabilistic. + for range 50 { + w := httptest.NewRecorder() + handler.ServeHTTP(w, httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", http.NoBody)) + + assert.Equal(t, allowedHeaders+", X-Canonical", w.Header().Get(headerAccessControlAllowHeaders), + "the exactly-canonical spelling must win, on every request") + } +} + +// TestCanonicalizeConfig_Precedence covers the fold directly, including the tie-break among +// non-canonical spellings where no key is the canonical one. +func TestCanonicalizeConfig_Precedence(t *testing.T) { + tests := []struct { + name string + in map[string]string + want map[string]string + }{ + { + name: "canonical spelling beats every other", + in: map[string]string{"x-custom": "lower", "X-Custom": "canonical", "X-CUSTOM": "upper"}, + want: map[string]string{"X-Custom": "canonical"}, + }, + { + name: "without a canonical spelling the smallest key wins", + in: map[string]string{"x-custom": "lower", "X-CUSTOM": "upper"}, + want: map[string]string{"X-Custom": "upper"}, + }, + { + name: "distinct headers are all kept", + in: map[string]string{"x-one": "1", "X-Two": "2"}, + want: map[string]string{"X-One": "1", "X-Two": "2"}, + }, + { + name: "empty config stays empty", + in: map[string]string{}, + want: map[string]string{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Repeated, because the property under test is independence from map iteration order. + for range 50 { + assert.Equal(t, tt.want, canonicalizeConfig(tt.in)) + } + }) + } +} diff --git a/pkg/gofr/http/middleware/logger_test.go b/pkg/gofr/http/middleware/logger_test.go index d8857063c6..9649736dac 100644 --- a/pkg/gofr/http/middleware/logger_test.go +++ b/pkg/gofr/http/middleware/logger_test.go @@ -5,17 +5,23 @@ import ( "bytes" "context" "encoding/json" + "errors" + "fmt" "net" "net/http" "net/http/httptest" "strings" + "sync" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + sdktrace "go.opentelemetry.io/otel/sdk/trace" + "go.opentelemetry.io/otel/trace" "gofr.dev/pkg/gofr/logging" + "gofr.dev/pkg/gofr/service" "gofr.dev/pkg/gofr/testutil" ) @@ -491,3 +497,1316 @@ func BenchmarkGetIPAddress(b *testing.B) { _ = getIPAddress(req) } } + +// --------------------------------------------------------------------------- +// Characterization suite: HTTP logging middleware + RequestLog wire format. +// +// Everything below is a *characterization* test: it pins the behavior of the +// code exactly as it is today (bugs included) so that any future refactor — +// including replacing encoding/json with a hand-rolled encoder — has to +// reproduce the current bytes verbatim. Nothing here asserts what the code +// *should* do; deviations found while writing these tests are documented in +// comments rather than fixed. +// +// All identifiers added here are prefixed `logChar` to stay collision-free with +// the rest of the package's tests. +// --------------------------------------------------------------------------- + +// logCharStartTimeLayout mirrors the layout string handleRequestLog passes to +// time.Format. Duplicated (not referenced) on purpose: if production changes +// the layout, this test must fail rather than silently follow. +const logCharStartTimeLayout = "2006-01-02T15:04:05.999999999-07:00" + +// logCharPanicEnvelope is the exact body panicRecovery writes. The map is a +// map[string]any, so encoding/json sorts the keys alphabetically +// (code, message, status) — NOT declaration order — and json.Encoder.Encode +// appends a trailing newline. +const logCharPanicEnvelope = `{"code":500,"message":"Some unexpected error has occurred","status":"ERROR"}` + "\n" + +// errLogCharPanic is the sentinel used by the panic(error) case. Named errFoo +// to satisfy revive's error-naming rule. +var errLogCharPanic = errors.New("boom from an error value") + +// logCharRecord is one captured call into the middleware's `logger` interface. +type logCharRecord struct { + level string // "LOG" or "ERROR" + arg any // the single argument the middleware passes +} + +// logCharRecorder implements the package-private `logger` interface and records +// every call, preserving order and which method (Log vs Error) was used. +type logCharRecorder struct { + mu sync.Mutex + records []logCharRecord +} + +func (r *logCharRecorder) Log(args ...any) { r.capture("LOG", args) } + +func (r *logCharRecorder) Error(args ...any) { r.capture("ERROR", args) } + +func (r *logCharRecorder) capture(level string, args []any) { + r.mu.Lock() + defer r.mu.Unlock() + + var a any + if len(args) == 1 { + a = args[0] + } else { + a = args + } + + r.records = append(r.records, logCharRecord{level: level, arg: a}) +} + +func (r *logCharRecorder) all() []logCharRecord { + r.mu.Lock() + defer r.mu.Unlock() + + return append([]logCharRecord(nil), r.records...) +} + +// requestLog returns the i-th record asserted to be a *RequestLog. +func (r *logCharRecorder) requestLog(t *testing.T, i int) *RequestLog { + t.Helper() + + recs := r.all() + require.Greater(t, len(recs), i, "expected at least %d log record(s)", i+1) + + rl, ok := recs[i].arg.(*RequestLog) + require.True(t, ok, "record %d is %T, want *RequestLog", i, recs[i].arg) + + return rl +} + +// logCharFullRequestLog is a RequestLog with every field non-zero, used as the +// baseline for wire-format assertions. +func logCharFullRequestLog() RequestLog { + return RequestLog{ + TraceID: "e1f2d3c4b5a6978877665544332211ff", + SpanID: "0011223344556677", + StartTime: "2024-03-01T12:34:56.789-05:00", + ResponseTime: 1234, + Method: http.MethodGet, + UserAgent: "curl/8.4.0", + IP: "192.0.2.10", + URI: "/api/v1/users?q=1", + Response: 200, + } +} + +// logCharNewRequest builds a request bound to the test context. +func logCharNewRequest(t *testing.T, method, target string) *http.Request { + t.Helper() + + req, err := http.NewRequestWithContext(t.Context(), method, target, http.NoBody) + require.NoError(t, err) + + return req +} + +// logCharServe runs one request through a freshly built Logging middleware. +func logCharServe(t *testing.T, probes LogProbes, l logger, h http.HandlerFunc, + req *http.Request) *httptest.ResponseRecorder { + t.Helper() + + rr := httptest.NewRecorder() + Logging(probes, l)(h).ServeHTTP(rr, req) + + return rr +} + +// logCharStatusHandler returns a handler that writes the given status. +func logCharStatusHandler(status int) http.HandlerFunc { + return func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(status) + } +} + +// --------------------------------------------------------------------------- +// A. RequestLog JSON wire format +// --------------------------------------------------------------------------- + +// Test_LoggingContract_RequestLogJSONShape pins the complete serialized form of +// a fully populated RequestLog: every field name, the exact field ORDER +// (encoding/json emits struct fields in declaration order, which is +// deterministic), and the JSON types — response_time and response are JSON +// numbers, never strings. +func Test_LoggingContract_RequestLogJSONShape(t *testing.T) { + const want = `{"trace_id":"e1f2d3c4b5a6978877665544332211ff","span_id":"0011223344556677",` + + `"start_time":"2024-03-01T12:34:56.789-05:00","response_time":1234,"method":"GET",` + + `"user_agent":"curl/8.4.0","ip":"192.0.2.10","uri":"/api/v1/users?q=1","response":200}` + + rl := logCharFullRequestLog() + + byValue, err := json.Marshal(rl) + require.NoError(t, err) + assert.JSONEq(t, want, string(byValue)) + //nolint:testifylint // byte equality is the contract; JSONEq ignores key order. + assert.Equal(t, []byte(want), byValue, "field order must stay in struct declaration order") + + // The middleware always logs a *RequestLog; a pointer must marshal + // byte-identically to the value. + byPointer, err := json.Marshal(&rl) + require.NoError(t, err) + //nolint:testifylint // byte equality is the contract; JSONEq ignores key order. + assert.Equal(t, []byte(want), byPointer, "a pointer marshals byte-identically to the value") + + // Types, asserted structurally so a future encoder cannot quote the numbers. + var generic map[string]any + require.NoError(t, json.Unmarshal(byValue, &generic)) + assert.IsType(t, float64(0), generic["response_time"], "response_time must be a JSON number") + assert.IsType(t, float64(0), generic["response"], "response must be a JSON number") + assert.IsType(t, "", generic["trace_id"]) + assert.Len(t, generic, 9, "RequestLog has exactly 9 wire fields") +} + +// Test_LoggingContract_RequestLogFieldOrderKeys pins the ordered key list on its +// own, independent of the values, so a field insertion is caught immediately. +func Test_LoggingContract_RequestLogFieldOrderKeys(t *testing.T) { + want := []string{ + "trace_id", "span_id", "start_time", "response_time", + "method", "user_agent", "ip", "uri", "response", + } + + b, err := json.Marshal(logCharFullRequestLog()) + require.NoError(t, err) + + dec := json.NewDecoder(strings.NewReader(string(b))) + + tok, err := dec.Token() + require.NoError(t, err) + require.Equal(t, json.Delim('{'), tok) + + got := make([]string, 0, len(want)) + + for dec.More() { + k, kerr := dec.Token() + require.NoError(t, kerr) + + got = append(got, k.(string)) + + var discard any + + require.NoError(t, dec.Decode(&discard)) + } + + assert.Equal(t, want, got) +} + +// Test_LoggingContract_RequestLogOmitempty pins exactly which fields DISAPPEAR +// when they hold their zero value. Every field carries `omitempty`, so a 0 +// response_time, a 0 response status, and empty strings all vanish from the +// wire — consumers cannot rely on the keys being present. +func Test_LoggingContract_RequestLogOmitempty(t *testing.T) { + const base = `{"trace_id":"e1f2d3c4b5a6978877665544332211ff","span_id":"0011223344556677",` + + `"start_time":"2024-03-01T12:34:56.789-05:00","response_time":1234,"method":"GET",` + + `"user_agent":"curl/8.4.0","ip":"192.0.2.10","uri":"/api/v1/users?q=1","response":200}` + + tests := []struct { + name string + mutate func(*RequestLog) + want string + }{ + {"all fields set", func(*RequestLog) {}, base}, + { + "zero response_time drops the key", + func(rl *RequestLog) { rl.ResponseTime = 0 }, + `{"trace_id":"e1f2d3c4b5a6978877665544332211ff","span_id":"0011223344556677",` + + `"start_time":"2024-03-01T12:34:56.789-05:00","method":"GET",` + + `"user_agent":"curl/8.4.0","ip":"192.0.2.10","uri":"/api/v1/users?q=1","response":200}`, + }, + { + "zero response drops the key", + func(rl *RequestLog) { rl.Response = 0 }, + `{"trace_id":"e1f2d3c4b5a6978877665544332211ff","span_id":"0011223344556677",` + + `"start_time":"2024-03-01T12:34:56.789-05:00","response_time":1234,"method":"GET",` + + `"user_agent":"curl/8.4.0","ip":"192.0.2.10","uri":"/api/v1/users?q=1"}`, + }, + { + "empty user_agent, ip and uri drop their keys", + func(rl *RequestLog) { rl.UserAgent, rl.IP, rl.URI = "", "", "" }, + `{"trace_id":"e1f2d3c4b5a6978877665544332211ff","span_id":"0011223344556677",` + + `"start_time":"2024-03-01T12:34:56.789-05:00","response_time":1234,` + + `"method":"GET","response":200}`, + }, + { + "empty method drops the key", + func(rl *RequestLog) { rl.Method = "" }, + `{"trace_id":"e1f2d3c4b5a6978877665544332211ff","span_id":"0011223344556677",` + + `"start_time":"2024-03-01T12:34:56.789-05:00","response_time":1234,` + + `"user_agent":"curl/8.4.0","ip":"192.0.2.10","uri":"/api/v1/users?q=1","response":200}`, + }, + { + "empty trace_id and span_id drop their keys", + func(rl *RequestLog) { rl.TraceID, rl.SpanID = "", "" }, + `{"start_time":"2024-03-01T12:34:56.789-05:00","response_time":1234,"method":"GET",` + + `"user_agent":"curl/8.4.0","ip":"192.0.2.10","uri":"/api/v1/users?q=1","response":200}`, + }, + { + "empty start_time drops the key", + func(rl *RequestLog) { rl.StartTime = "" }, + `{"trace_id":"e1f2d3c4b5a6978877665544332211ff","span_id":"0011223344556677",` + + `"response_time":1234,"method":"GET",` + + `"user_agent":"curl/8.4.0","ip":"192.0.2.10","uri":"/api/v1/users?q=1","response":200}`, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + rl := logCharFullRequestLog() + tc.mutate(&rl) + + b, err := json.Marshal(&rl) + require.NoError(t, err) + assert.Equal(t, tc.want, string(b)) + }) + } +} + +// Test_LoggingContract_RequestLogZeroValueIsEmptyObject is the extreme +// omitempty case: a zero RequestLog serializes to "{}" — no keys at all. +func Test_LoggingContract_RequestLogZeroValueIsEmptyObject(t *testing.T) { + b, err := json.Marshal(&RequestLog{}) + require.NoError(t, err) + assert.JSONEq(t, `{}`, string(b)) + assert.Equal(t, `{}`, string(b)) +} + +// Test_LoggingContract_RequestLogEscaping pins encoding/json's exact escaping +// for hostile field values. These byte strings are the contract: a hand-rolled +// encoder must reproduce them character for character. +// +// Notable behaviors pinned here: +// - `<`, `>` and `&` become \u003c, \u003e, \u0026 (encoding/json HTML-escapes +// by default, for both Marshal and Encoder). +// - `'` is NOT escaped. +// - Control chars use the short forms \n \r \t \b \f where they exist and +// \u00XX otherwise; DEL (U+007F) is NOT escaped and passes through raw. +// - U+2028 / U+2029 ARE escaped (JS line separators). +// - Non-ASCII (CJK, emoji, accented) passes through as raw UTF-8, unescaped. +// - Invalid UTF-8 bytes are replaced by the escaped form of U+FFFD. +func Test_LoggingContract_RequestLogEscaping(t *testing.T) { + tests := []struct { + name string + in RequestLog + want string + }{ + { + "double quote and backslash in uri", + RequestLog{URI: "/a\"b\\c"}, + `{"uri":"/a\"b\\c"}`, + }, + { + "control characters use short forms where they exist, \\u00XX otherwise", + RequestLog{URI: "/a\nb\tc\x00d\x1fe\rf\bg\fx"}, + `{"uri":"/a\nb\tc\u0000d\u001fe\rf\bg\fx"}`, + }, + { + "DEL 0x7f is NOT escaped", + RequestLog{URI: "/\x7f/end"}, + "{\"uri\":\"/\x7f/end\"}", + }, + { + "HTML significant characters in uri are escaped", + RequestLog{URI: "/q?x=&y='z'"}, + `{"uri":"/q?x=\u003ca\u003e\u0026y='z'"}`, + }, + { + "HTML significant characters in user_agent are escaped", + RequestLog{UserAgent: "Mozilla/5.0 "}, + `{"user_agent":"Mozilla/5.0 \u003cscript\u003ealert(\"x\")\u0026\u003c/script\u003e"}`, + }, + { + "tab and HTML characters in ip", + RequestLog{IP: "10.0.0.1\t"}, + `{"ip":"10.0.0.1\t\u003cb\u003e"}`, + }, + { + "quotes, backslash and HTML characters in method", + RequestLog{Method: "GE\"T\\<>&"}, + `{"method":"GE\"T\\\u003c\u003e\u0026"}`, + }, + { + "CJK, emoji and combining marks pass through as raw UTF-8", + RequestLog{URI: "/\u65e5\u672c\u8a9e/\U0001F680/e\u0301"}, + "{\"uri\":\"/\u65e5\u672c\u8a9e/\U0001F680/e\u0301\"}", + }, + { + "line and paragraph separators are escaped", + RequestLog{URI: "/\u2028\u2029"}, + `{"uri":"/\u2028\u2029"}`, + }, + { + "invalid UTF-8 bytes become the escaped replacement character", + RequestLog{URI: "/\xff\xfe/ok"}, + `{"uri":"/\ufffd\ufffd/ok"}`, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + b, err := json.Marshal(&tc.in) + require.NoError(t, err, "encoding/json never fails on a RequestLog, even with invalid UTF-8") + assert.Equal(t, tc.want, string(b)) + }) + } +} + +// Test_LoggingContract_MarshalVersusEncoder pins the difference between the two +// encoding entry points. The production path is json.NewEncoder(out).Encode in +// pkg/gofr/logging — so the real log line ends with exactly one "\n" and HTML +// escaping is ON (Encoder defaults to SetEscapeHTML(true), same as Marshal). +func Test_LoggingContract_MarshalVersusEncoder(t *testing.T) { + const body = `{"uri":"/q?a=\u003cb\u003e\u0026c=1","response":200}` + + rl := &RequestLog{URI: "/q?a=&c=1", Response: 200} + + marshaled, err := json.Marshal(rl) + require.NoError(t, err) + //nolint:testifylint // byte equality is the contract; JSONEq ignores key order. + assert.Equal(t, []byte(body), marshaled, "json.Marshal adds no trailing newline") + + var buf bytes.Buffer + require.NoError(t, json.NewEncoder(&buf).Encode(rl)) + //nolint:testifylint // byte equality is the contract; JSONEq ignores key order. + assert.Equal(t, []byte(body+"\n"), buf.Bytes(), "json.Encoder.Encode appends exactly one newline") + assert.Equal(t, 1, strings.Count(buf.String(), "\n")) +} + +// Test_LoggingContract_StartTimeLayout pins the rendered shape of the +// "2006-01-02T15:04:05.999999999-07:00" layout used for start_time. The +// `.999999999` verb TRIMS trailing zeros, so a whole-second instant renders +// with NO fractional part at all — a consumer parsing a fixed-width timestamp +// will break. The `-07:00` verb always renders a numeric offset, so UTC becomes +// "+00:00" rather than "Z". +func Test_LoggingContract_StartTimeLayout(t *testing.T) { + plus0530 := time.FixedZone("+0530", 5*3600+1800) + minus0500 := time.FixedZone("-0500", -5*3600) + + tests := []struct { + name string + in time.Time + want string + }{ + {"whole second in UTC has no fractional part", time.Date(2024, 3, 1, 12, 34, 56, 0, time.UTC), "2024-03-01T12:34:56+00:00"}, + {"whole second keeps the numeric offset", time.Date(2024, 3, 1, 12, 34, 56, 0, plus0530), "2024-03-01T12:34:56+05:30"}, + {"milliseconds", time.Date(2024, 3, 1, 12, 34, 56, 123000000, time.UTC), "2024-03-01T12:34:56.123+00:00"}, + {"microseconds", time.Date(2024, 3, 1, 12, 34, 56, 123456000, time.UTC), "2024-03-01T12:34:56.123456+00:00"}, + {"nanoseconds", time.Date(2024, 3, 1, 12, 34, 56, 123456789, time.UTC), "2024-03-01T12:34:56.123456789+00:00"}, + {"trailing zeros are trimmed", time.Date(2024, 3, 1, 12, 34, 56, 100000000, time.UTC), "2024-03-01T12:34:56.1+00:00"}, + {"leading zeros are kept", time.Date(2024, 3, 1, 12, 34, 56, 1, time.UTC), "2024-03-01T12:34:56.000000001+00:00"}, + {"negative offset", time.Date(2024, 3, 1, 12, 34, 56, 500000000, minus0500), "2024-03-01T12:34:56.5-05:00"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, tc.in.Format(logCharStartTimeLayout)) + }) + } +} + +// Test_LoggingContract_StartTimeIsParseableFromMiddleware confirms the layout +// pinned above is the one the middleware really uses, by round-tripping the +// start_time emitted for a real request. +func Test_LoggingContract_StartTimeIsParseableFromMiddleware(t *testing.T) { + rec := &logCharRecorder{} + req := logCharNewRequest(t, http.MethodGet, "http://dummy/x") + + logCharServe(t, LogProbes{}, rec, logCharStatusHandler(http.StatusOK), req) + + rl := rec.requestLog(t, 0) + + parsed, err := time.Parse(logCharStartTimeLayout, rl.StartTime) + require.NoError(t, err, "start_time %q must parse with the production layout", rl.StartTime) + assert.WithinDuration(t, time.Now(), parsed, time.Minute) + + // Parsing alone is too weak a pin: time.Parse is lenient about the number + // of fractional digits, so a production layout of ".000000000" (fixed + // 9 digits, no trailing-zero trimming) would still parse here. Re-render + // the parsed instant with the layout pinned above and require byte + // equality — that fails the moment the production layout verb changes. + assert.Equal(t, rl.StartTime, parsed.Format(logCharStartTimeLayout), + "start_time must round-trip byte-for-byte through the pinned layout") +} + +// --------------------------------------------------------------------------- +// B. Middleware behavior +// --------------------------------------------------------------------------- + +// Test_LoggingContract_StatusRoutesLogVsError pins the severity routing +// threshold at exactly 500: anything below goes to logger.Log, 500 and above to +// logger.Error. 499 vs 500 is the boundary pair. +func Test_LoggingContract_StatusRoutesLogVsError(t *testing.T) { + tests := []struct { + name string + status int + want string + }{ + {"200 is logged at Log level", http.StatusOK, "LOG"}, + {"204 is logged at Log level", http.StatusNoContent, "LOG"}, + {"301 is logged at Log level", http.StatusMovedPermanently, "LOG"}, + {"400 is logged at Log level", http.StatusBadRequest, "LOG"}, + {"404 is logged at Log level", http.StatusNotFound, "LOG"}, + {"499 is logged at Log level", 499, "LOG"}, + {"500 crosses to Error level", http.StatusInternalServerError, "ERROR"}, + {"503 is logged at Error level", http.StatusServiceUnavailable, "ERROR"}, + {"599 is logged at Error level", 599, "ERROR"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + rec := &logCharRecorder{} + req := logCharNewRequest(t, http.MethodGet, "http://dummy/status") + + rr := logCharServe(t, LogProbes{}, rec, logCharStatusHandler(tc.status), req) + + records := rec.all() + require.Len(t, records, 1, "exactly one log line per request") + assert.Equal(t, tc.want, records[0].level) + assert.Equal(t, tc.status, rec.requestLog(t, 0).Response) + assert.Equal(t, tc.status, rr.Code) + }) + } +} + +// Test_LoggingContract_HandlerWritesNothingLogs200 pins the implicit-200 +// normalization: a handler that never calls WriteHeader or Write still logs +// response 200 (Status() maps the internal 0 to http.StatusOK) — so the +// omitempty on `response` never actually elides the key in practice. +func Test_LoggingContract_HandlerWritesNothingLogs200(t *testing.T) { + rec := &logCharRecorder{} + req := logCharNewRequest(t, http.MethodGet, "http://dummy/empty") + + logCharServe(t, LogProbes{}, rec, func(http.ResponseWriter, *http.Request) {}, req) + + records := rec.all() + require.Len(t, records, 1) + assert.Equal(t, "LOG", records[0].level) + assert.Equal(t, http.StatusOK, rec.requestLog(t, 0).Response) + + b, err := json.Marshal(rec.requestLog(t, 0)) + require.NoError(t, err) + assert.Contains(t, string(b), `"response":200`) +} + +// Test_LoggingContract_RequestLogPopulatedFields pins the value shapes the +// middleware fills in for a realistic request. +func Test_LoggingContract_RequestLogPopulatedFields(t *testing.T) { + rec := &logCharRecorder{} + req := logCharNewRequest(t, http.MethodPost, "http://dummy/api/v1/users?q=1") + req.RequestURI = "/api/v1/users?q=1" + req.Header.Set("User-Agent", "gofr-test/1.0") + req.RemoteAddr = "198.51.100.7:44321" + + logCharServe(t, LogProbes{}, rec, logCharStatusHandler(http.StatusCreated), req) + + rl := rec.requestLog(t, 0) + assert.Equal(t, http.MethodPost, rl.Method) + assert.Equal(t, "gofr-test/1.0", rl.UserAgent) + assert.Equal(t, "198.51.100.7:44321", rl.IP, "RemoteAddr is used verbatim, port included") + assert.Equal(t, "/api/v1/users?q=1", rl.URI, "uri comes from r.RequestURI, not r.URL") + assert.Equal(t, http.StatusCreated, rl.Response) + assert.Equal(t, zeroTraceID, rl.TraceID) + assert.Equal(t, zeroSpanID, rl.SpanID) + assert.GreaterOrEqual(t, rl.ResponseTime, int64(0), "response_time is microseconds, derived from time.Since") +} + +// Test_LoggingContract_ResponseTimeIsMicroseconds pins the UNIT of the +// response_time field. The exact number is wall-clock dependent, so instead of +// a value we pin an order-of-magnitude window around a handler that sleeps a +// known duration: 20ms is 20_000µs, which falls inside the window below but +// would land far outside it if the field were ever switched to nanoseconds +// (20_000_000) or milliseconds (20). +func Test_LoggingContract_ResponseTimeIsMicroseconds(t *testing.T) { + const sleep = 20 * time.Millisecond + + rec := &logCharRecorder{} + req := logCharNewRequest(t, http.MethodGet, "http://dummy/slow") + + logCharServe(t, LogProbes{}, rec, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + time.Sleep(sleep) + w.WriteHeader(http.StatusOK) + }), req) + + rl := rec.requestLog(t, 0) + + // Lower bound is half the sleep (coarse timers / CI jitter can under-report + // slightly); upper bound is 100x the sleep, which a scheduler hiccup can + // plausibly reach but a unit change cannot fit inside. + assert.Greater(t, rl.ResponseTime, int64(sleep/time.Microsecond)/2, + "response_time %d is too small to be microseconds for a %v handler", rl.ResponseTime, sleep) + assert.Less(t, rl.ResponseTime, int64(sleep/time.Microsecond)*100, + "response_time %d is too large to be microseconds for a %v handler", rl.ResponseTime, sleep) +} + +// Test_LoggingContract_PanicRecoveryShapes pins panic handling for all three +// branches of panicRecovery's type switch, the 500 status, and the EXACT bytes +// of the JSON envelope written to the client. +func Test_LoggingContract_PanicRecoveryShapes(t *testing.T) { + tests := []struct { + name string + panicWith any + wantError string + }{ + {"panic with a string uses the string verbatim", "boom from a string", "boom from a string"}, + {"panic with an error uses Error()", errLogCharPanic, "boom from an error value"}, + {"panic with any other type is labeled", struct{ A int }{1}, "Unknown panic type"}, + {"panic with an int is labeled", 42, "Unknown panic type"}, + {"panic with nil-ish non-error value is labeled", []string{"x"}, "Unknown panic type"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + rec := &logCharRecorder{} + req := logCharNewRequest(t, http.MethodGet, "http://dummy/panic") + + rr := logCharServe(t, LogProbes{}, rec, func(http.ResponseWriter, *http.Request) { + panic(tc.panicWith) + }, req) + + logCharAssertPanicRecord(t, rec, tc.wantError) + + assert.Equal(t, http.StatusInternalServerError, rr.Code) + //nolint:testifylint // byte equality is the contract; JSONEq ignores key order. + assert.Equal(t, logCharPanicEnvelope, rr.Body.String(), + "panic envelope keys are map-sorted (code, message, status) with a trailing newline") + }) + } +} + +// logCharAssertPanicRecord pins the panicLog record: it is always the LAST +// record, always at Error level, and always a panicLog value (not a pointer). +func logCharAssertPanicRecord(t *testing.T, rec *logCharRecorder, wantErr string) { + t.Helper() + + records := rec.all() + require.NotEmpty(t, records) + + last := records[len(records)-1] + assert.Equal(t, "ERROR", last.level) + + pl, ok := last.arg.(panicLog) + require.True(t, ok, "panic record is %T, want panicLog", last.arg) + assert.Equal(t, wantErr, pl.Error) + assert.NotEmpty(t, pl.StackTrace) + + b, err := json.Marshal(pl) + require.NoError(t, err) + assert.True(t, strings.HasPrefix(string(b), `{"error":"`), "panicLog field order is error then stack_trace") + assert.Contains(t, string(b), `","stack_trace":"`) +} + +// Test_LoggingContract_PanicLogJSONShape pins the panicLog wire format, +// including its omitempty behavior. +func Test_LoggingContract_PanicLogJSONShape(t *testing.T) { + b, err := json.Marshal(panicLog{Error: "boom", StackTrace: "goroutine 1 [running]:"}) + require.NoError(t, err) + //nolint:testifylint // byte equality is the contract; JSONEq ignores key order. + assert.Equal(t, `{"error":"boom","stack_trace":"goroutine 1 [running]:"}`, string(b)) + + empty, err := json.Marshal(panicLog{}) + require.NoError(t, err) + assert.Equal(t, `{}`, string(empty), "both panicLog fields are omitempty") + + only, err := json.Marshal(panicLog{Error: "boom"}) + require.NoError(t, err) + //nolint:testifylint // byte equality is the contract; JSONEq ignores key order. + assert.Equal(t, `{"error":"boom"}`, string(only)) +} + +// Test_LoggingContract_PanicAlsoEmitsRequestLogFirst pins a subtle and +// surprising ordering consequence of the defer registration order in Logging: +// +// defer pool.Put (registered 1st -> runs 3rd) +// defer panicRecovery (registered 2nd -> runs 2nd) +// defer handleRequestLog (registered 3rd -> runs 1st) +// +// So on a panic the REQUEST log is emitted BEFORE the panic log, and because +// panicRecovery has not yet written the 500 at that point, the request log +// records response 200 for a request the client sees as a 500. +func Test_LoggingContract_PanicAlsoEmitsRequestLogFirst(t *testing.T) { + rec := &logCharRecorder{} + req := logCharNewRequest(t, http.MethodGet, "http://dummy/panic") + + rr := logCharServe(t, LogProbes{}, rec, func(http.ResponseWriter, *http.Request) { + panic("kaboom") + }, req) + + records := rec.all() + require.Len(t, records, 2, "a panic emits both a request log and a panic log") + + rl, ok := records[0].arg.(*RequestLog) + require.True(t, ok, "the request log comes FIRST") + assert.Equal(t, "LOG", records[0].level, + "request log is routed to Log, not Error, because Status() is still 200 when it runs") + assert.Equal(t, http.StatusOK, rl.Response, + "request log reports 200 even though the client receives 500") + + _, ok = records[1].arg.(panicLog) + assert.True(t, ok, "the panic log comes SECOND") + + assert.Equal(t, http.StatusInternalServerError, rr.Code) +} + +// Test_LoggingContract_PanicAfterWriteKeepsFirstStatus pins that WriteHeader is +// idempotent: when the handler has already committed a status, panicRecovery's +// WriteHeader(500) is a no-op and the envelope is APPENDED to whatever the +// handler already wrote. +func Test_LoggingContract_PanicAfterWriteKeepsFirstStatus(t *testing.T) { + rec := &logCharRecorder{} + req := logCharNewRequest(t, http.MethodGet, "http://dummy/panic-after-write") + + rr := logCharServe(t, LogProbes{}, rec, func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusTeapot) + _, _ = w.Write([]byte("partial")) + + panic("late boom") + }, req) + + assert.Equal(t, http.StatusTeapot, rr.Code, "the first WriteHeader wins; no superfluous-header panic") + assert.Equal(t, "partial"+logCharPanicEnvelope, rr.Body.String()) + assert.Equal(t, http.StatusTeapot, rec.requestLog(t, 0).Response) +} + +// Test_LoggingContract_PanicOnProbePathSkipsRequestLog pins that the probe-skip +// early return happens AFTER the panicRecovery defer is registered, so panics +// on probe paths are still recovered and logged — but no request log is emitted. +func Test_LoggingContract_PanicOnProbePathSkipsRequestLog(t *testing.T) { + rec := &logCharRecorder{} + probes := LogProbes{Disabled: true, Paths: []string{service.HealthPath, service.AlivePath}} + req := logCharNewRequest(t, http.MethodGet, "http://dummy"+service.HealthPath) + + rr := logCharServe(t, probes, rec, func(http.ResponseWriter, *http.Request) { + panic("probe boom") + }, req) + + records := rec.all() + require.Len(t, records, 1, "only the panic log; no request log on a skipped path") + + pl, ok := records[0].arg.(panicLog) + require.True(t, ok) + assert.Equal(t, "probe boom", pl.Error) + assert.Equal(t, http.StatusInternalServerError, rr.Code) + //nolint:testifylint // byte equality is the contract; JSONEq ignores key order. + assert.Equal(t, logCharPanicEnvelope, rr.Body.String()) +} + +// Test_LoggingContract_ProbeFiltering pins which (Disabled, Paths, urlPath) +// combinations suppress the request log entirely. +func Test_LoggingContract_ProbeFiltering(t *testing.T) { + defaults := []string{service.HealthPath, service.AlivePath} + + tests := []struct { + name string + probes LogProbes + path string + wantLogs int + }{ + {"disabled + health path is silent", LogProbes{Disabled: true, Paths: defaults}, service.HealthPath, 0}, + {"disabled + alive path is silent", LogProbes{Disabled: true, Paths: defaults}, service.AlivePath, 0}, + {"disabled + unrelated path still logs", LogProbes{Disabled: true, Paths: defaults}, "/api/users", 1}, + {"disabled + prefix of a probe path still logs", LogProbes{Disabled: true, Paths: defaults}, "/.well-known", 1}, + {"disabled + probe path with trailing slash still logs", + LogProbes{Disabled: true, Paths: defaults}, service.HealthPath + "/", 1}, + {"not disabled + health path still logs", LogProbes{Disabled: false, Paths: defaults}, service.HealthPath, 1}, + {"not disabled + alive path still logs", LogProbes{Disabled: false, Paths: defaults}, service.AlivePath, 1}, + {"disabled with empty path list logs everything", LogProbes{Disabled: true}, service.HealthPath, 1}, + {"disabled with a custom path is silent", LogProbes{Disabled: true, Paths: []string{"/ping"}}, "/ping", 0}, + {"zero-value LogProbes logs everything", LogProbes{}, service.HealthPath, 1}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + rec := &logCharRecorder{} + req := logCharNewRequest(t, http.MethodGet, "http://dummy"+tc.path) + + logCharServe(t, tc.probes, rec, logCharStatusHandler(http.StatusOK), req) + + assert.Len(t, rec.all(), tc.wantLogs) + }) + } +} + +// Test_LoggingContract_ProbePathPassesRawWriterToHandler pins a real asymmetry +// in Logging: on the skipped (probe) path it calls inner.ServeHTTP(w, r) with +// the RAW ResponseWriter, while on the normal path it calls +// inner.ServeHTTP(srw, r) with the pooled *StatusResponseWriter. +// +// Consequence: on probe paths the downstream chain (and the handler) do NOT see +// a *StatusResponseWriter. Any downstream middleware that type-asserts on it — +// pkg/gofr/http/middleware/tracer.go does exactly that — silently falls back to +// its own wrapper. Reported as a latent inconsistency, pinned here as-is. +func Test_LoggingContract_ProbePathPassesRawWriterToHandler(t *testing.T) { + probes := LogProbes{Disabled: true, Paths: []string{service.HealthPath, service.AlivePath}} + + var ( + skippedIsWrapped bool + normalIsWrapped bool + ) + + handler := func(target *bool) http.HandlerFunc { + return func(w http.ResponseWriter, _ *http.Request) { + _, ok := w.(*StatusResponseWriter) + *target = ok + } + } + + logCharServe(t, probes, &logCharRecorder{}, handler(&skippedIsWrapped), + logCharNewRequest(t, http.MethodGet, "http://dummy"+service.HealthPath)) + + logCharServe(t, probes, &logCharRecorder{}, handler(&normalIsWrapped), + logCharNewRequest(t, http.MethodGet, "http://dummy/api/users")) + + assert.False(t, skippedIsWrapped, "probe path hands the handler the RAW ResponseWriter") + assert.True(t, normalIsWrapped, "normal path hands the handler the pooled *StatusResponseWriter") +} + +// Test_LoggingContract_ProbePathStillSetsCorrelationID pins that the +// X-Correlation-ID header is set BEFORE the probe short-circuit, so probe +// responses still carry it even though nothing is logged. +func Test_LoggingContract_ProbePathStillSetsCorrelationID(t *testing.T) { + rec := &logCharRecorder{} + probes := LogProbes{Disabled: true, Paths: []string{service.HealthPath, service.AlivePath}} + req := logCharNewRequest(t, http.MethodGet, "http://dummy"+service.AlivePath) + + rr := logCharServe(t, probes, rec, logCharStatusHandler(http.StatusOK), req) + + assert.Empty(t, rec.all()) + assert.Equal(t, zeroTraceID, rr.Header().Get("X-Correlation-ID")) +} + +// Test_LoggingContract_IsLogProbeDisabled pins the predicate directly. +func Test_LoggingContract_IsLogProbeDisabled(t *testing.T) { + paths := []string{service.HealthPath, service.AlivePath} + + tests := []struct { + name string + probes LogProbes + path string + want bool + }{ + {"health path with probes disabled", LogProbes{Disabled: true, Paths: paths}, service.HealthPath, true}, + {"alive path with probes disabled", LogProbes{Disabled: true, Paths: paths}, service.AlivePath, true}, + {"health path with probes enabled", LogProbes{Disabled: false, Paths: paths}, service.HealthPath, false}, + {"unknown path with probes disabled", LogProbes{Disabled: true, Paths: paths}, "/other", false}, + {"empty path list", LogProbes{Disabled: true, Paths: nil}, service.HealthPath, false}, + {"empty string path matches an empty entry", LogProbes{Disabled: true, Paths: []string{""}}, "", true}, + {"match is exact, not prefix", LogProbes{Disabled: true, Paths: paths}, service.HealthPath + "?x=1", false}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, isLogProbeDisabled(tc.probes, tc.path)) + }) + } +} + +// Test_LoggingContract_CorrelationIDWithoutSpan pins the no-trace default: the +// header and both log ID fields carry the all-zero W3C strings, not "". +func Test_LoggingContract_CorrelationIDWithoutSpan(t *testing.T) { + rec := &logCharRecorder{} + req := logCharNewRequest(t, http.MethodGet, "http://dummy/no-span") + + rr := logCharServe(t, LogProbes{}, rec, logCharStatusHandler(http.StatusOK), req) + + assert.Equal(t, "00000000000000000000000000000000", rr.Header().Get("X-Correlation-ID")) + assert.Len(t, rr.Header().Get("X-Correlation-ID"), 32) + + rl := rec.requestLog(t, 0) + assert.Equal(t, "00000000000000000000000000000000", rl.TraceID) + assert.Equal(t, "0000000000000000", rl.SpanID) + assert.Len(t, rl.SpanID, 16) +} + +// Test_LoggingContract_CorrelationIDWithRecordingSpan pins that a real sampled +// span's IDs flow into both the header and the log line. Uses a locally +// constructed TracerProvider so no OTel process globals are touched. +func Test_LoggingContract_CorrelationIDWithRecordingSpan(t *testing.T) { + tp := sdktrace.NewTracerProvider() + + t.Cleanup(func() { _ = tp.Shutdown(t.Context()) }) + + ctx, span := tp.Tracer("logchar").Start(t.Context(), "logchar-op") + defer span.End() + + require.True(t, span.IsRecording()) + require.True(t, span.SpanContext().IsValid()) + + rec := &logCharRecorder{} + req := logCharNewRequest(t, http.MethodGet, "http://dummy/traced").WithContext(ctx) + + rr := logCharServe(t, LogProbes{}, rec, logCharStatusHandler(http.StatusOK), req) + + wantTrace := span.SpanContext().TraceID().String() + wantSpan := span.SpanContext().SpanID().String() + + assert.Equal(t, wantTrace, rr.Header().Get("X-Correlation-ID")) + assert.Equal(t, wantTrace, rec.requestLog(t, 0).TraceID) + assert.Equal(t, wantSpan, rec.requestLog(t, 0).SpanID) + assert.NotEqual(t, zeroTraceID, wantTrace) +} + +// Test_LoggingContract_CorrelationIDWithNeverSampledSpan pins the default +// no-exporter deployment shape described in pkg/gofr/otel.go: a NeverSample +// provider still mints VALID trace/span IDs, so correlation IDs stay unique +// per request even though the span is not recording. +func Test_LoggingContract_CorrelationIDWithNeverSampledSpan(t *testing.T) { + tp := sdktrace.NewTracerProvider(sdktrace.WithSampler(sdktrace.NeverSample())) + + t.Cleanup(func() { _ = tp.Shutdown(t.Context()) }) + + ctx, span := tp.Tracer("logchar").Start(t.Context(), "logchar-op") + defer span.End() + + require.False(t, span.IsRecording(), "NeverSample spans do not record") + require.True(t, span.SpanContext().IsValid(), "but the SpanContext is still valid") + + rec := &logCharRecorder{} + req := logCharNewRequest(t, http.MethodGet, "http://dummy/never-sampled").WithContext(ctx) + + rr := logCharServe(t, LogProbes{}, rec, logCharStatusHandler(http.StatusOK), req) + + wantTrace := span.SpanContext().TraceID().String() + + assert.Equal(t, wantTrace, rr.Header().Get("X-Correlation-ID")) + assert.NotEqual(t, zeroTraceID, wantTrace) + assert.Equal(t, wantTrace, rec.requestLog(t, 0).TraceID) + assert.Equal(t, span.SpanContext().SpanID().String(), rec.requestLog(t, 0).SpanID) +} + +// Test_LoggingContract_CorrelationIDWithRemoteSpanContext pins that a +// SpanContext injected directly (as the W3C propagator would after extracting +// traceparent) is honored without any TracerProvider involved. +func Test_LoggingContract_CorrelationIDWithRemoteSpanContext(t *testing.T) { + traceID, err := trace.TraceIDFromHex("4bf92f3577b34da6a3ce929d0e0e4736") + require.NoError(t, err) + + spanID, err := trace.SpanIDFromHex("00f067aa0ba902b7") // spellchecker:disable-line + require.NoError(t, err) + + sc := trace.NewSpanContext(trace.SpanContextConfig{ + TraceID: traceID, SpanID: spanID, TraceFlags: trace.FlagsSampled, Remote: true, + }) + ctx := trace.ContextWithSpanContext(t.Context(), sc) + + rec := &logCharRecorder{} + req := logCharNewRequest(t, http.MethodGet, "http://dummy/remote").WithContext(ctx) + + rr := logCharServe(t, LogProbes{}, rec, logCharStatusHandler(http.StatusOK), req) + + assert.Equal(t, "4bf92f3577b34da6a3ce929d0e0e4736", rr.Header().Get("X-Correlation-ID")) + assert.Equal(t, "4bf92f3577b34da6a3ce929d0e0e4736", rec.requestLog(t, 0).TraceID) + assert.Equal(t, "00f067aa0ba902b7", rec.requestLog(t, 0).SpanID) // spellchecker:disable-line +} + +// Test_LoggingContract_GetIPAddress pins X-Forwarded-For parsing, including two +// sharp edges: +// - RemoteAddr is used VERBATIM, port included — it is never split. +// - A whitespace-only XFF header yields the EMPTY string rather than falling +// back to RemoteAddr, because the empty check runs BEFORE TrimSpace. The +// resulting empty `ip` is then dropped from the log line by omitempty. +func Test_LoggingContract_GetIPAddress(t *testing.T) { + const remote = "10.0.0.9:54321" + + tests := []struct { + name string + xff string + want string + }{ + {"no XFF falls back to RemoteAddr with its port", "", remote}, + {"single XFF entry", "203.0.113.5", "203.0.113.5"}, + {"comma list takes the first entry", "203.0.113.5, 70.41.3.18, 150.172.238.178", "203.0.113.5"}, + {"first entry is trimmed", " 203.0.113.5 , 70.41.3.18", "203.0.113.5"}, + {"surrounding whitespace on a single entry is trimmed", "\t203.0.113.5 ", "203.0.113.5"}, + {"XFF with a port keeps the port", "192.168.0.1:8080", "192.168.0.1:8080"}, + {"lone comma falls back to RemoteAddr", ",", remote}, + {"leading comma falls back to RemoteAddr", ",203.0.113.5", remote}, + {"whitespace-only XFF yields the empty string, NOT RemoteAddr", " ", ""}, + {"whitespace before a comma yields the empty string", " , 203.0.113.5", ""}, + {"IPv6 entry", "2001:db8::1, 203.0.113.5", "2001:db8::1"}, + {"trailing comma keeps the first entry", "203.0.113.5,", "203.0.113.5"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + req := logCharNewRequest(t, http.MethodGet, "http://dummy/ip") + req.RemoteAddr = remote + + if tc.xff != "" { + req.Header.Set("X-Forwarded-For", tc.xff) + } + + assert.Equal(t, tc.want, getIPAddress(req)) + }) + } +} + +// Test_LoggingContract_EmptyIPIsOmittedFromWire pins the omitempty consequence +// of the whitespace-only XFF edge above: the `ip` key disappears entirely. +func Test_LoggingContract_EmptyIPIsOmittedFromWire(t *testing.T) { + rec := &logCharRecorder{} + req := logCharNewRequest(t, http.MethodGet, "http://dummy/ip") + req.RequestURI = "/ip" + req.RemoteAddr = "10.0.0.9:54321" + req.Header.Set("X-Forwarded-For", " ") + + logCharServe(t, LogProbes{}, rec, logCharStatusHandler(http.StatusOK), req) + + rl := rec.requestLog(t, 0) + assert.Empty(t, rl.IP) + + b, err := json.Marshal(rl) + require.NoError(t, err) + assert.NotContains(t, string(b), `"ip"`, "an empty ip is dropped from the wire by omitempty") +} + +// Test_LoggingContract_EmptyUserAgentIsOmittedFromWire pins the same for +// user_agent, which is trivially reachable (most non-browser clients send none). +func Test_LoggingContract_EmptyUserAgentIsOmittedFromWire(t *testing.T) { + rec := &logCharRecorder{} + req := logCharNewRequest(t, http.MethodGet, "http://dummy/ua") + req.RequestURI = "/ua" + + logCharServe(t, LogProbes{}, rec, logCharStatusHandler(http.StatusOK), req) + + rl := rec.requestLog(t, 0) + assert.Empty(t, rl.UserAgent) + + b, err := json.Marshal(rl) + require.NoError(t, err) + assert.NotContains(t, string(b), `"user_agent"`) +} + +// Test_LoggingContract_PoolResetsStateAcrossRequests pins that the pooled +// StatusResponseWriter is fully reset per request: a 500 followed by a +// write-nothing request through the SAME middleware instance must log 500 then +// 200, never a leaked 500. +func Test_LoggingContract_PoolResetsStateAcrossRequests(t *testing.T) { + rec := &logCharRecorder{} + mw := Logging(LogProbes{}, rec) + + first := mw(logCharStatusHandler(http.StatusInternalServerError)) + first.ServeHTTP(httptest.NewRecorder(), logCharNewRequest(t, http.MethodGet, "http://dummy/one")) + + second := mw(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) + rr := httptest.NewRecorder() + second.ServeHTTP(rr, logCharNewRequest(t, http.MethodGet, "http://dummy/two")) + + records := rec.all() + require.Len(t, records, 2) + assert.Equal(t, "ERROR", records[0].level) + assert.Equal(t, http.StatusInternalServerError, rec.requestLog(t, 0).Response) + assert.Equal(t, "LOG", records[1].level) + assert.Equal(t, http.StatusOK, rec.requestLog(t, 1).Response, "wroteHeader/status must be reset on pool Get") + assert.Equal(t, http.StatusOK, rr.Code) +} + +// Test_LoggingContract_PoolNilsResponseWriterAfterRequest pins that the pooled +// wrapper's ResponseWriter pointer is cleared once the request completes, so a +// stale writer cannot leak across requests through the pool. +func Test_LoggingContract_PoolNilsResponseWriterAfterRequest(t *testing.T) { + var captured *StatusResponseWriter + + rec := &logCharRecorder{} + req := logCharNewRequest(t, http.MethodGet, "http://dummy/pool") + + logCharServe(t, LogProbes{}, rec, func(w http.ResponseWriter, _ *http.Request) { + captured, _ = w.(*StatusResponseWriter) + + w.WriteHeader(http.StatusOK) + }, req) + + require.NotNil(t, captured) + assert.Nil(t, captured.ResponseWriter, "ResponseWriter is nil'd before the wrapper returns to the pool") + assert.Equal(t, http.StatusOK, captured.Status(), "status/wroteHeader are NOT cleared on Put, only on the next Get") +} + +// Test_LoggingContract_DoubleWrapPropagatesStatus pins the production chain +// shape: Tracer wraps the writer, then Logging wraps it AGAIN from its pool. +// The status a handler writes must surface through both layers. +func Test_LoggingContract_DoubleWrapPropagatesStatus(t *testing.T) { + rec := &logCharRecorder{} + rr := httptest.NewRecorder() + outer := &StatusResponseWriter{ResponseWriter: rr} + + Logging(LogProbes{}, rec)(logCharStatusHandler(http.StatusServiceUnavailable)). + ServeHTTP(outer, logCharNewRequest(t, http.MethodGet, "http://dummy/double")) + + assert.Equal(t, http.StatusServiceUnavailable, outer.Status(), "outer wrapper sees the status") + assert.Equal(t, http.StatusServiceUnavailable, rr.Code, "the real writer sees the status") + assert.Equal(t, http.StatusServiceUnavailable, rec.requestLog(t, 0).Response) + assert.Equal(t, zeroTraceID, rr.Header().Get("X-Correlation-ID"), "header set through both wrappers") +} + +// --------------------------------------------------------------------------- +// C. StatusResponseWriter +// --------------------------------------------------------------------------- + +// Test_LoggingContract_SRWDuplicateWriteHeader pins that the second (and any +// later) WriteHeader call is dropped without a "superfluous response.WriteHeader +// call" from net/http. +func Test_LoggingContract_SRWDuplicateWriteHeader(t *testing.T) { + rr := httptest.NewRecorder() + srw := &StatusResponseWriter{ResponseWriter: rr} + + srw.WriteHeader(http.StatusTeapot) + srw.WriteHeader(http.StatusOK) + srw.WriteHeader(http.StatusBadGateway) + + assert.Equal(t, http.StatusTeapot, srw.Status()) + assert.Equal(t, http.StatusTeapot, rr.Code) + assert.True(t, srw.wroteHeader) +} + +// Test_LoggingContract_SRWWriteThenWriteHeader pins that a bare Write commits an +// implicit 200 and a subsequent WriteHeader cannot change it. +func Test_LoggingContract_SRWWriteThenWriteHeader(t *testing.T) { + rr := httptest.NewRecorder() + srw := &StatusResponseWriter{ResponseWriter: rr} + + n, err := srw.Write([]byte("hello")) + require.NoError(t, err) + assert.Equal(t, 5, n) + assert.Equal(t, http.StatusOK, srw.Status()) + + srw.WriteHeader(http.StatusInternalServerError) + + assert.Equal(t, http.StatusOK, srw.Status(), "the implicit 200 from Write wins") + assert.Equal(t, "hello", rr.Body.String()) +} + +// Test_LoggingContract_SRWStatusDefaultsTo200 pins Status()'s zero-normalization +// on an untouched writer. +func Test_LoggingContract_SRWStatusDefaultsTo200(t *testing.T) { + srw := &StatusResponseWriter{ResponseWriter: httptest.NewRecorder()} + + assert.Equal(t, 0, srw.status, "the raw field is still zero") + assert.Equal(t, http.StatusOK, srw.Status(), "Status() normalizes zero to 200") + assert.False(t, srw.wroteHeader) +} + +// Test_LoggingContract_SRWUnwrap pins that Unwrap returns the exact wrapped +// writer and that http.NewResponseController reaches Flush through it. +func Test_LoggingContract_SRWUnwrap(t *testing.T) { + rr := httptest.NewRecorder() + srw := &StatusResponseWriter{ResponseWriter: rr} + + assert.Same(t, rr, srw.Unwrap()) + + _, err := srw.Write([]byte("chunk")) + require.NoError(t, err) + + require.NoError(t, http.NewResponseController(srw).Flush()) + assert.True(t, rr.Flushed, "Flush reached the recorder via Unwrap") + assert.Equal(t, "chunk", rr.Body.String()) +} + +// Test_LoggingContract_SRWResponseControllerUnsupported pins that capabilities +// the wrapped writer does not have surface as http.ErrNotSupported rather than +// being masked by the wrapper. +func Test_LoggingContract_SRWResponseControllerUnsupported(t *testing.T) { + srw := &StatusResponseWriter{ResponseWriter: httptest.NewRecorder()} + + err := http.NewResponseController(srw).SetWriteDeadline(time.Now().Add(time.Second)) + require.Error(t, err) + assert.ErrorIs(t, err, http.ErrNotSupported) +} + +// Test_LoggingContract_SRWHijackNotSupported pins the exact error produced when +// the wrapped writer is not an http.Hijacker, including the sentinel wrapping. +func Test_LoggingContract_SRWHijackNotSupported(t *testing.T) { + srw := &StatusResponseWriter{ResponseWriter: httptest.NewRecorder()} + + conn, rw, err := srw.Hijack() + + require.Error(t, err) + assert.Nil(t, conn) + assert.Nil(t, rw) + require.ErrorIs(t, err, errHijackNotSupported) + assert.Equal(t, "response writer does not support hijacking: cannot hijack connection", err.Error()) + assert.Equal(t, "response writer does not support hijacking", errHijackNotSupported.Error()) +} + +// --------------------------------------------------------------------------- +// D. PrettyPrint (terminal path) +// --------------------------------------------------------------------------- + +// Test_LoggingContract_PrettyPrintExactBytes pins the exact ANSI byte sequence +// PrettyPrint emits, including the %-6d status padding, the %8d duration +// padding, and the trailing " \n" (space then newline). +func Test_LoggingContract_PrettyPrintExactBytes(t *testing.T) { + const ( + grey = "\u001B[38;5;8m" + reset = "\u001B[0m" + ) + + tests := []struct { + name string + status int + want string + }{ + {"2xx uses color 34", 200, grey + "abc \u001B[38;5;34m200 " + reset + " 42" + grey + "µs" + reset + " GET /x \n"}, + {"4xx uses color 220", 404, grey + "abc \u001B[38;5;220m404 " + reset + " 42" + grey + "µs" + reset + " GET /x \n"}, + {"5xx uses color 202", 500, grey + "abc \u001B[38;5;202m500 " + reset + " 42" + grey + "µs" + reset + " GET /x \n"}, + {"1xx falls through to color 0", 100, grey + "abc \u001B[38;5;0m100 " + reset + " 42" + grey + "µs" + reset + " GET /x \n"}, + {"3xx falls through to color 0", 302, grey + "abc \u001B[38;5;0m302 " + reset + " 42" + grey + "µs" + reset + " GET /x \n"}, + {"6xx falls through to color 0", 600, grey + "abc \u001B[38;5;0m600 " + reset + " 42" + grey + "µs" + reset + " GET /x \n"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + rl := &RequestLog{TraceID: "abc", Response: tc.status, ResponseTime: 42, Method: http.MethodGet, URI: "/x"} + + var buf bytes.Buffer + rl.PrettyPrint(&buf) + + assert.Equal(t, tc.want, buf.String()) + }) + } +} + +// Test_LoggingContract_PrettyPrintPaddingOverflow pins that the %-6d and %8d +// widths are MINIMUMS: oversized values push the line wider rather than being +// truncated, and there is no separator besides the padding. +func Test_LoggingContract_PrettyPrintPaddingOverflow(t *testing.T) { + rl := &RequestLog{TraceID: "t", Response: 1234567, ResponseTime: 1234567890, Method: "PATCH", URI: "/long/path"} + + var buf bytes.Buffer + rl.PrettyPrint(&buf) + + want := "\u001B[38;5;8mt \u001B[38;5;0m1234567\u001B[0m 1234567890\u001B[38;5;8mµs\u001B[0m PATCH /long/path \n" + assert.Equal(t, want, buf.String()) +} + +// Test_LoggingContract_PrettyPrintEmptyRequestLog pins the zero-value render — +// PrettyPrint applies no omitempty logic, so empty fields become empty columns. +func Test_LoggingContract_PrettyPrintEmptyRequestLog(t *testing.T) { + var buf bytes.Buffer + (&RequestLog{}).PrettyPrint(&buf) + + want := "\u001B[38;5;8m \u001B[38;5;0m0 \u001B[0m 0\u001B[38;5;8mµs\u001B[0m \n" + assert.Equal(t, want, buf.String()) +} + +// Test_LoggingContract_ColorForStatusCodeBoundaries pins every boundary of the +// status -> color mapping used by PrettyPrint. +func Test_LoggingContract_ColorForStatusCodeBoundaries(t *testing.T) { + tests := []struct { + status int + want int + }{ + {0, 0}, {100, 0}, {199, 0}, + {200, 34}, {299, 34}, + {300, 0}, {399, 0}, + {400, 220}, {499, 220}, + {500, 202}, {599, 202}, + {600, 0}, {-1, 0}, + } + + for _, tc := range tests { + t.Run(fmt.Sprintf("status_%d", tc.status), func(t *testing.T) { + assert.Equal(t, tc.want, colorForStatusCode(tc.status)) + }) + } +} + +// --------------------------------------------------------------------------- +// E. Misc +// --------------------------------------------------------------------------- + +// Test_LoggingContract_HandleRequestLogNilLogger pins that a nil logger is a +// silent no-op in handleRequestLog rather than a nil-pointer panic. (Note: +// panicRecovery has no such guard — it calls logger.Error unconditionally.) +func Test_LoggingContract_HandleRequestLogNilLogger(t *testing.T) { + srw := &StatusResponseWriter{ResponseWriter: httptest.NewRecorder()} + req := logCharNewRequest(t, http.MethodGet, "http://dummy/nil-logger") + + assert.NotPanics(t, func() { + handleRequestLog(srw, req, time.Now(), zeroTraceID, zeroSpanID, nil) + }) +} + +// Test_LoggingContract_ZeroIDConstants pins the literal zero-ID constants used +// when no valid SpanContext is in scope. +func Test_LoggingContract_ZeroIDConstants(t *testing.T) { + assert.Equal(t, "00000000000000000000000000000000", zeroTraceID) + assert.Equal(t, "0000000000000000", zeroSpanID) + assert.Equal(t, zeroTraceID, trace.TraceID{}.String(), "matches the W3C invalid TraceID rendering") + assert.Equal(t, zeroSpanID, trace.SpanID{}.String(), "matches the W3C invalid SpanID rendering") +} + +// Test_LoggingContract_MethodIsNotNormalized pins that the log records +// r.Method verbatim — no upper-casing (unlike the Tracer middleware, which does +// strings.ToUpper for the span name). +func Test_LoggingContract_MethodIsNotNormalized(t *testing.T) { + rec := &logCharRecorder{} + + req := logCharNewRequest(t, http.MethodGet, "http://dummy/case") + req.Method = "get" + + logCharServe(t, LogProbes{}, rec, logCharStatusHandler(http.StatusOK), req) + + assert.Equal(t, "get", rec.requestLog(t, 0).Method) +} + +// Test_LoggingContract_OneLogPerRequest pins that a normal request produces +// exactly one record and that repeated requests do not accumulate duplicates. +func Test_LoggingContract_OneLogPerRequest(t *testing.T) { + rec := &logCharRecorder{} + mw := Logging(LogProbes{}, rec)(logCharStatusHandler(http.StatusOK)) + + const n = 5 + + for i := range n { + mw.ServeHTTP(httptest.NewRecorder(), logCharNewRequest(t, http.MethodGet, fmt.Sprintf("http://dummy/%d", i))) + } + + assert.Len(t, rec.all(), n) +} + +// Test_LoggingContract_ConcurrentRequestsShareThePool exercises the sync.Pool +// under -race with concurrent requests and pins that each request still gets an +// independent, correctly-scoped status. +func Test_LoggingContract_ConcurrentRequestsShareThePool(t *testing.T) { + rec := &logCharRecorder{} + mw := Logging(LogProbes{}, rec) + + const n = 32 + + var wg sync.WaitGroup + + for i := range n { + wg.Add(1) + + go func(i int) { + defer wg.Done() + + status := http.StatusOK + if i%2 == 0 { + status = http.StatusInternalServerError + } + + h := mw(logCharStatusHandler(status)) + rr := httptest.NewRecorder() + req, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, "http://dummy/c", http.NoBody) + h.ServeHTTP(rr, req) + + assert.Equal(t, status, rr.Code) + }(i) + } + + wg.Wait() + + records := rec.all() + require.Len(t, records, n) + + var errs int + + for _, r := range records { + if r.level == "ERROR" { + errs++ + } + } + + assert.Equal(t, n/2, errs, "half the requests were 500s; no status leaked across pooled writers") +} diff --git a/pkg/gofr/http/middleware/metrics_test.go b/pkg/gofr/http/middleware/metrics_test.go index bb60873a1d..927e4fc821 100644 --- a/pkg/gofr/http/middleware/metrics_test.go +++ b/pkg/gofr/http/middleware/metrics_test.go @@ -5,11 +5,16 @@ import ( "net/http" "net/http/httptest" "os" + "strconv" + "strings" + "sync" "testing" + "time" "github.com/gorilla/mux" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" + "go.opentelemetry.io/otel/attribute" ) type mockMetrics struct { @@ -196,3 +201,1016 @@ func TestMetrics_StaticFileWithQueryParam(t *testing.T) { mockMetrics.AssertCalled(t, "RecordHistogram", mock.Anything, "app_http_response", mock.Anything, []string{"path", "/static/example.js", "method", "GET", "status", "200"}) } + +// --------------------------------------------------------------------------- +// Characterization suite for the Metrics HTTP middleware. +// +// Everything below pins CURRENT behavior of pkg/gofr/http/middleware/metrics.go +// exactly as it is today. Where the behavior looks like a latent bug the test +// still asserts the observed value and says so in a comment — these tests are +// a tripwire, not a specification of what SHOULD happen. +// +// All identifiers introduced here are prefixed with metChar to stay collision +// free with the other _test.go files in this package. +// --------------------------------------------------------------------------- + +// Recorded call kinds captured by metCharRecorder. +const ( + metCharKindHistogram = "RecordHistogram" + metCharKindAttrs = "RecordHistogramAttrs" + metCharKindCounter = "IncrementCounter" + metCharKindUpDown = "DeltaUpDownCounter" + metCharKindGauge = "SetGauge" +) + +// metCharMetricName is the only metric the middleware is allowed to emit. +const metCharMetricName = "app_http_response" + +// metCharCall is a single, fully captured metrics call. +type metCharCall struct { + kind string + name string + value float64 + labels []string + attrs []attribute.KeyValue +} + +// metCharRecorder implements the unexported `metrics` interface ONLY, so the +// middleware takes the slow (string varargs) path. It is mutex protected +// because the concurrency test drives it from many goroutines under -race. +type metCharRecorder struct { + mu sync.Mutex + calls []metCharCall +} + +func (f *metCharRecorder) record(c *metCharCall) { + f.mu.Lock() + defer f.mu.Unlock() + + f.calls = append(f.calls, *c) +} + +// all returns a copy of every call recorded so far. +func (f *metCharRecorder) all() []metCharCall { + f.mu.Lock() + defer f.mu.Unlock() + + out := make([]metCharCall, len(f.calls)) + copy(out, f.calls) + + return out +} + +// one asserts that exactly one metrics call was recorded and returns it. +func (f *metCharRecorder) one(t *testing.T) *metCharCall { + t.Helper() + + calls := f.all() + require.Len(t, calls, 1, "expected exactly one recorded metrics call") + + return &calls[0] +} + +func (f *metCharRecorder) IncrementCounter(_ context.Context, name string, labels ...string) { + f.record(&metCharCall{kind: metCharKindCounter, name: name, labels: labels}) +} + +func (f *metCharRecorder) DeltaUpDownCounter(_ context.Context, name string, value float64, labels ...string) { + f.record(&metCharCall{kind: metCharKindUpDown, name: name, value: value, labels: labels}) +} + +func (f *metCharRecorder) RecordHistogram(_ context.Context, name string, value float64, labels ...string) { + f.record(&metCharCall{kind: metCharKindHistogram, name: name, value: value, labels: append([]string(nil), labels...)}) +} + +func (f *metCharRecorder) SetGauge(name string, value float64, labels ...string) { + f.record(&metCharCall{kind: metCharKindGauge, name: name, value: value, labels: labels}) +} + +// metCharAttrRecorder additionally implements the unexported metricsAttrer +// optional interface, which switches the middleware onto the fast path. +type metCharAttrRecorder struct { + *metCharRecorder +} + +func (f *metCharAttrRecorder) RecordHistogramAttrs(_ context.Context, name string, + value float64, attrs ...attribute.KeyValue) { + f.record(&metCharCall{ + kind: metCharKindAttrs, + name: name, + value: value, + attrs: append([]attribute.KeyValue(nil), attrs...), + }) +} + +func metCharNewAttrRecorder() *metCharAttrRecorder { + return &metCharAttrRecorder{metCharRecorder: &metCharRecorder{}} +} + +// metCharLabelSet normalizes a recorded call to a flat key/value string slice +// so the fast and slow paths can be compared directly. For the fast path it +// also pins that EVERY attribute value is of type attribute.STRING. +func metCharLabelSet(t *testing.T, c *metCharCall) []string { + t.Helper() + + if c.kind == metCharKindHistogram { + return c.labels + } + + labels := make([]string, 0, len(c.attrs)*2) + + for _, kv := range c.attrs { + require.Equal(t, attribute.STRING, kv.Value.Type(), + "attribute %q must carry a STRING value, got %v", kv.Key, kv.Value.Type()) + + labels = append(labels, string(kv.Key), kv.Value.AsString()) + } + + return labels +} + +// metCharOKHandler writes an explicit 200. +func metCharOKHandler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }) +} + +// metCharRegister builds a plain mux Path() registration for tpl. +func metCharRegister(tpl string) func(r *mux.Router, h http.Handler) { + return func(r *mux.Router, h http.Handler) { + r.Handle(tpl, h) + } +} + +// metCharChain builds the handler under test. When register is nil the Metrics +// middleware wraps h directly, so mux.CurrentRoute(r) is nil (the 404 shape). +// The returned handler owns exactly ONE Metrics instance, so repeated requests +// through it exercise the routeAttrs/statusAttrs caches. +func metCharChain(m metrics, register func(r *mux.Router, h http.Handler), h http.Handler) http.Handler { + if register == nil { + return Metrics(m)(h) + } + + router := mux.NewRouter() + register(router, h) + router.Use(Metrics(m)) + + return router +} + +// metCharServe drives one request through handler. +func metCharServe(t *testing.T, handler http.Handler, method, target string) { + t.Helper() + + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, httptest.NewRequestWithContext(t.Context(), method, target, http.NoBody)) +} + +// metCharWant builds the canonical expected label slice in the exact order the +// middleware emits it. +func metCharWant(path, method, status string) []string { + return []string{"path", path, "method", method, "status", status} +} + +// --------------------------------------------------------------------------- +// 1. Metric identity, value unit, and exact slow-path label varargs. +// --------------------------------------------------------------------------- + +// Test_MetricsContractSlowPathExactCall pins that an implementation satisfying +// only the `metrics` interface receives exactly one RecordHistogram call, with +// the exact metric name, a duration expressed in SECONDS as a float64, and the +// exact ordered varargs slice ["path", p, "method", m, "status", ""] +// where status is a STRING produced by fmt.Sprintf("%d"). +func Test_MetricsContractSlowPathExactCall(t *testing.T) { + rec := &metCharRecorder{} + + slow := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + time.Sleep(5 * time.Millisecond) + w.WriteHeader(http.StatusOK) + }) + + handler := metCharChain(rec, metCharRegister("/users/{id}"), slow) + metCharServe(t, handler, http.MethodGet, "/users/42") + + call := rec.one(t) + + require.Equal(t, metCharKindHistogram, call.kind, "slow path must use RecordHistogram") + require.Equal(t, "app_http_response", call.name, "metric name is fixed") + require.Equal(t, metCharMetricName, call.name) + + // Value is duration.Seconds(): a float64 in seconds, so a 5ms handler must + // land above 0.004 and comfortably below one second. This pins the UNIT — + // a switch to milliseconds/nanoseconds would break it. + require.Positive(t, call.value, "duration must be strictly positive") + require.Greater(t, call.value, 0.004, "5ms handler must record >= ~0.005 seconds") + require.Less(t, call.value, 1.0, "value must be seconds, not milliseconds/nanos") + + require.Equal(t, metCharWant("/users/{id}", http.MethodGet, "200"), call.labels, + "exact ordered varargs label slice") + + // status is a string, never an int. + require.Equal(t, "200", call.labels[5]) + require.IsType(t, "", call.labels[5]) +} + +// Test_MetricsContractNoOtherMetricsEmitted pins that the middleware emits the +// response histogram and nothing else — no counters, no gauges. +func Test_MetricsContractNoOtherMetricsEmitted(t *testing.T) { + rec := &metCharRecorder{} + handler := metCharChain(rec, metCharRegister("/ping"), metCharOKHandler()) + + metCharServe(t, handler, http.MethodGet, "/ping") + + calls := rec.all() + // Guard the loop: with no recorded calls the assertions below would not run + // at all and the test would pass even if the middleware emitted nothing. + require.Len(t, calls, 1, "exactly one metric is emitted per request") + + for _, c := range calls { + require.Equal(t, metCharKindHistogram, c.kind, "only RecordHistogram may be called") + require.Equal(t, metCharMetricName, c.name) + } +} + +// --------------------------------------------------------------------------- +// 2. Fast path (metricsAttrer) — exact attribute.KeyValue triple. +// --------------------------------------------------------------------------- + +// Test_MetricsContractFastPathExactAttrs pins that when the metrics +// implementation also provides RecordHistogramAttrs, the middleware calls it +// (and never RecordHistogram) with exactly three attributes, in order: +// path(string), method(string), status(STRING — not Int). +func Test_MetricsContractFastPathExactAttrs(t *testing.T) { + rec := metCharNewAttrRecorder() + handler := metCharChain(rec, metCharRegister("/users/{id}"), metCharOKHandler()) + + metCharServe(t, handler, http.MethodGet, "/users/42") + + call := rec.one(t) + + require.Equal(t, metCharKindAttrs, call.kind, "fast path must use RecordHistogramAttrs") + require.Equal(t, metCharMetricName, call.name) + require.GreaterOrEqual(t, call.value, 0.0) + require.Less(t, call.value, 1.0, "value is seconds") + + require.Len(t, call.attrs, 3, "exactly three attributes") + + require.Equal(t, attribute.Key("path"), call.attrs[0].Key) + require.Equal(t, attribute.STRING, call.attrs[0].Value.Type()) + require.Equal(t, "/users/{id}", call.attrs[0].Value.AsString()) + + require.Equal(t, attribute.Key("method"), call.attrs[1].Key) + require.Equal(t, attribute.STRING, call.attrs[1].Value.Type()) + require.Equal(t, http.MethodGet, call.attrs[1].Value.AsString()) + + // The status attribute is deliberately a STRING, matching the slow path's + // fmt.Sprintf("%d"). attribute.Int would change the OTLP wire type. + require.Equal(t, attribute.Key("status"), call.attrs[2].Key) + require.Equal(t, attribute.STRING, call.attrs[2].Value.Type(), + "status must be attribute.String, NOT attribute.Int") + require.Equal(t, "200", call.attrs[2].Value.AsString()) + + // RecordHistogram must not be called at all on the fast path. + for _, c := range rec.all() { + require.NotEqual(t, metCharKindHistogram, c.kind, + "RecordHistogram must not be used when RecordHistogramAttrs exists") + } +} + +// Test_MetricsContractFastAndSlowPathsAgree pins that both code paths produce +// semantically identical label sets for the same traffic. +func Test_MetricsContractFastAndSlowPathsAgree(t *testing.T) { + type req struct { + route string + method string + target string + } + + reqs := []req{ + {"/users/{id}", http.MethodGet, "/users/42"}, + {"/users/{id}", http.MethodPost, "/users/7"}, + {"/assets/{name}", http.MethodGet, "/assets/logo.png"}, + {"/", http.MethodGet, "/"}, + {"/static/{f}", http.MethodGet, "/static/app"}, + } + + for _, r := range reqs { + t.Run(r.method+" "+r.target, func(t *testing.T) { + slowRec := &metCharRecorder{} + fastRec := metCharNewAttrRecorder() + + metCharServe(t, metCharChain(slowRec, metCharRegister(r.route), metCharOKHandler()), r.method, r.target) + metCharServe(t, metCharChain(fastRec, metCharRegister(r.route), metCharOKHandler()), r.method, r.target) + + slow := slowRec.one(t) + fast := fastRec.one(t) + + require.Equal(t, slow.name, fast.name) + require.Equal(t, metCharLabelSet(t, slow), metCharLabelSet(t, fast), + "fast and slow paths must produce identical labels") + }) + } +} + +// --------------------------------------------------------------------------- +// 3. Path label resolution. +// --------------------------------------------------------------------------- + +// metCharPathCase is one path-label characterization case. +type metCharPathCase struct { + name string + route string // mux Path() template; empty means "no router at all". + register func(r *mux.Router, h http.Handler) + method string + target string + wantPath string +} + +func metCharRunPathCases(t *testing.T, cases []metCharPathCase) { + t.Helper() + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + register := tc.register + if register == nil && tc.route != "" { + register = metCharRegister(tc.route) + } + + rec := &metCharRecorder{} + handler := metCharChain(rec, register, metCharOKHandler()) + + metCharServe(t, handler, tc.method, tc.target) + + call := rec.one(t) + require.Equal(t, metCharMetricName, call.name) + require.Equal(t, metCharWant(tc.wantPath, tc.method, "200"), call.labels) + }) + } +} + +// Test_MetricsContractPathLabelResolution pins how the path label is derived +// from the mux route template versus the raw r.URL.Path. +func Test_MetricsContractPathLabelResolution(t *testing.T) { + metCharRunPathCases(t, []metCharPathCase{ + { + name: "route template wins over raw path", + route: "/users/{id}", + method: http.MethodGet, + target: "/users/42", + wantPath: "/users/{id}", + }, + { + name: "multi segment template", + route: "/orgs/{org}/repos/{repo}", + method: http.MethodGet, + target: "/orgs/gofr/repos/gofr", + wantPath: "/orgs/{org}/repos/{repo}", + }, + { + name: "no mux route at all falls back to raw path", + method: http.MethodGet, + target: "/no/such/route", + wantPath: "/no/such/route", + }, + { + name: "route without a Path matcher falls back to raw path", + register: func(r *mux.Router, h http.Handler) { r.NewRoute().Methods(http.MethodGet).Handler(h) }, + method: http.MethodGet, + target: "/anything/at/all", + wantPath: "/anything/at/all", + }, + { + name: "PathPrefix route collapses sub paths onto the prefix template", + register: func(r *mux.Router, h http.Handler) { r.PathPrefix("/api/").Handler(h) }, + method: http.MethodGet, + target: "/api/deeply/nested/thing", + wantPath: "/api", + }, + }) +} + +// Test_MetricsContractTrailingSlashTrimming pins strings.TrimSuffix(path, "/"). +// +// LATENT BUG pinned here: a request to exactly "/" is first forced onto the +// raw path ("/") and then trimmed, producing an EMPTY STRING as the path +// label. That is almost certainly unintended (an empty `path` dimension in +// Prometheus/OTLP), but it is current behavior and is characterized, not +// fixed. +func Test_MetricsContractTrailingSlashTrimming(t *testing.T) { + metCharRunPathCases(t, []metCharPathCase{ + { + name: "trailing slash trimmed from template", + route: "/users/", + method: http.MethodGet, + target: "/users/", + wantPath: "/users", + }, + { + name: "trailing slash trimmed from raw path when unrouted", + method: http.MethodGet, + target: "/raw/path/", + wantPath: "/raw/path", + }, + { + name: "root route yields an EMPTY path label (latent bug)", + route: "/", + method: http.MethodGet, + target: "/", + wantPath: "", + }, + { + name: "root request without a router also yields an EMPTY path label", + method: http.MethodGet, + target: "/", + wantPath: "", + }, + }) +} + +// Test_MetricsContractStaticPrefixForcesRawPath pins the `path == "/" || +// strings.HasPrefix(path, "/static")` branch. +// +// Note the check is applied to the RESOLVED path (usually the route TEMPLATE), +// not to r.URL.Path — and "/staticfiles" also satisfies HasPrefix("/static"), +// which is a very likely unintended prefix match. +func Test_MetricsContractStaticPrefixForcesRawPath(t *testing.T) { + metCharRunPathCases(t, []metCharPathCase{ + { + name: "template under /static forces the raw url path", + route: "/static/{file}", + method: http.MethodGet, + target: "/static/bundle", + wantPath: "/static/bundle", + }, + { + name: "template /staticfiles also matches HasPrefix /static (unintended)", + route: "/staticfiles/{id}", + method: http.MethodGet, + target: "/staticfiles/42", + wantPath: "/staticfiles/42", + }, + { + name: "template /statically also matches HasPrefix /static (unintended)", + route: "/statically/{id}", + method: http.MethodGet, + target: "/statically/9", + wantPath: "/statically/9", + }, + { + name: "url under /static but template elsewhere keeps the template", + route: "/{a}/{b}", + method: http.MethodGet, + target: "/static/thing", + wantPath: "/{a}/{b}", + }, + { + name: "template /staticky-ish sibling that does not match prefix keeps template", + route: "/stati/{id}", + method: http.MethodGet, + target: "/stati/1", + wantPath: "/stati/{id}", + }, + }) +} + +// --------------------------------------------------------------------------- +// 4. Static-file extension special case. +// --------------------------------------------------------------------------- + +// metCharStaticExts is the exact extension allow-list in metrics.go. +func metCharStaticExts() []string { + return []string{ + ".css", ".js", ".png", ".jpg", ".jpeg", ".gif", ".ico", + ".svg", ".txt", ".html", ".json", ".woff", ".woff2", ".ttf", ".eot", ".pdf", + } +} + +// Test_MetricsContractStaticExtensionForcesRawPath pins that for every +// extension in the allow-list the RAW url path replaces the matched route +// template. +func Test_MetricsContractStaticExtensionForcesRawPath(t *testing.T) { + for _, ext := range metCharStaticExts() { + t.Run(ext, func(t *testing.T) { + target := "/assets/logo" + ext + rec := &metCharRecorder{} + handler := metCharChain(rec, metCharRegister("/assets/{name}"), metCharOKHandler()) + + metCharServe(t, handler, http.MethodGet, target) + + require.Equal(t, metCharWant(target, http.MethodGet, "200"), rec.one(t).labels) + }) + } +} + +// Test_MetricsContractStaticExtensionIsCaseInsensitive pins that the extension +// is lower-cased before the switch, so upper and mixed case also force the raw +// path. +func Test_MetricsContractStaticExtensionIsCaseInsensitive(t *testing.T) { + targets := []string{ + "/assets/LOGO.PNG", + "/assets/style.CSS", + "/assets/data.JsOn", + "/assets/font.WOFF2", + "/assets/page.HtMl", + } + + for _, target := range targets { + t.Run(target, func(t *testing.T) { + rec := &metCharRecorder{} + handler := metCharChain(rec, metCharRegister("/assets/{name}"), metCharOKHandler()) + + metCharServe(t, handler, http.MethodGet, target) + + require.Equal(t, metCharWant(target, http.MethodGet, "200"), rec.one(t).labels) + }) + } +} + +// Test_MetricsContractNonStaticExtensionKeepsTemplate pins the negative side of +// the allow-list: extensions that are NOT listed leave the route template in +// place, so cardinality stays bounded. +func Test_MetricsContractNonStaticExtensionKeepsTemplate(t *testing.T) { + metCharRunPathCases(t, []metCharPathCase{ + { + name: "webp is not in the allow-list", + route: "/assets/{name}", + method: http.MethodGet, + target: "/assets/pic.webp", + wantPath: "/assets/{name}", + }, + { + name: "source map keeps template even though the name contains .js", + route: "/assets/{name}", + method: http.MethodGet, + target: "/assets/app.js.map", + wantPath: "/assets/{name}", + }, + { + name: "no extension at all keeps template", + route: "/assets/{name}", + method: http.MethodGet, + target: "/assets/logo", + wantPath: "/assets/{name}", + }, + { + name: "dot only in a directory segment is not an extension", + route: "/v1.0/{id}", + method: http.MethodGet, + target: "/v1.0/42", + wantPath: "/v1.0/{id}", + }, + { + name: "dotted directory plus static file still forces raw path", + route: "/v1.0/{name}", + method: http.MethodGet, + target: "/v1.0/logo.png", + wantPath: "/v1.0/logo.png", + }, + }) +} + +// Test_MetricsContractExtensionIgnoresQueryString pins that filepath.Ext is +// taken from r.URL.Path only — the query string neither contributes an +// extension nor leaks into the label. +func Test_MetricsContractExtensionIgnoresQueryString(t *testing.T) { + metCharRunPathCases(t, []metCharPathCase{ + { + name: "static file with query string records the bare path", + route: "/assets/{name}", + method: http.MethodGet, + target: "/assets/logo.png?v=42", + wantPath: "/assets/logo.png", + }, + { + name: "css only in the query string does not force the raw path", + route: "/assets/{name}", + method: http.MethodGet, + target: "/assets/logo?fallback=a.css", + wantPath: "/assets/{name}", + }, + }) +} + +// --------------------------------------------------------------------------- +// 5. /graphql skip. +// --------------------------------------------------------------------------- + +// Test_MetricsContractGraphQLRecordsNothing pins that when the RESOLVED path +// is exactly "/graphql" the middleware records nothing at all, while the inner +// handler still runs. +func Test_MetricsContractGraphQLRecordsNothing(t *testing.T) { + cases := []struct { + name string + route string + target string + }{ + {"routed /graphql", "/graphql", "/graphql"}, + {"unrouted /graphql", "", "/graphql"}, + {"unrouted /graphql/ trailing slash is trimmed then skipped", "", "/graphql/"}, + {"route template /graphql/ is trimmed then skipped", "/graphql/", "/graphql/"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var register func(r *mux.Router, h http.Handler) + if tc.route != "" { + register = metCharRegister(tc.route) + } + + var served bool + + inner := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + served = true + + w.WriteHeader(http.StatusOK) + }) + + rec := &metCharRecorder{} + metCharServe(t, metCharChain(rec, register, inner), http.MethodGet, tc.target) + + require.True(t, served, "inner handler must still run for /graphql") + require.Empty(t, rec.all(), "no metric may be recorded for /graphql") + }) + } +} + +// Test_MetricsContractGraphQLSkipIsPathBasedNotURLBased pins that the skip +// keys off the RESOLVED path label, not r.URL.Path: a wildcard route serving +// the /graphql url still records (with the template as the label), and a +// /graphql sub-path is not skipped. +func Test_MetricsContractGraphQLSkipIsPathBasedNotURLBased(t *testing.T) { + metCharRunPathCases(t, []metCharPathCase{ + { + name: "wildcard route serving /graphql is still recorded", + route: "/{resource}", + method: http.MethodPost, + target: "/graphql", + wantPath: "/{resource}", + }, + { + name: "graphql sub path is not skipped", + route: "/graphql/playground", + method: http.MethodGet, + target: "/graphql/playground", + wantPath: "/graphql/playground", + }, + { + name: "graphqlx is not skipped", + method: http.MethodGet, + target: "/graphqlx", + wantPath: "/graphqlx", + }, + }) +} + +// --------------------------------------------------------------------------- +// 6. Status label. +// --------------------------------------------------------------------------- + +// Test_MetricsContractStatusLabel pins the status label across explicit +// WriteHeader, implicit 200 via Write, and a handler that does nothing at all +// (StatusResponseWriter.Status normalizes 0 to 200 — the label is "200", never +// "0"). +func Test_MetricsContractStatusLabel(t *testing.T) { + cases := []struct { + name string + handler http.Handler + wantStatus string + }{ + { + name: "explicit 200", + handler: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }), + wantStatus: "200", + }, + { + name: "explicit 404", + handler: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + }), + wantStatus: "404", + }, + { + name: "explicit 500", + handler: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + }), + wantStatus: "500", + }, + { + name: "body only write implies 200", + handler: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte("hello")) + }), + wantStatus: "200", + }, + { + name: "handler writes nothing at all normalizes 0 to 200", + handler: http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {}), + wantStatus: "200", + }, + { + name: "first WriteHeader wins over a later one", + handler: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusTeapot) + w.WriteHeader(http.StatusInternalServerError) + }), + wantStatus: "418", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + slowRec := &metCharRecorder{} + fastRec := metCharNewAttrRecorder() + + metCharServe(t, metCharChain(slowRec, metCharRegister("/s"), tc.handler), http.MethodGet, "/s") + metCharServe(t, metCharChain(fastRec, metCharRegister("/s"), tc.handler), http.MethodGet, "/s") + + want := metCharWant("/s", http.MethodGet, tc.wantStatus) + + require.Equal(t, want, slowRec.one(t).labels) + require.Equal(t, want, metCharLabelSet(t, fastRec.one(t))) + }) + } +} + +// --------------------------------------------------------------------------- +// 7. Method label. +// --------------------------------------------------------------------------- + +// Test_MetricsContractMethodLabelIsRawRequestMethod pins that the method label +// is r.Method verbatim — it is NOT upper-cased (unlike the tracer middleware), +// so a lowercase "get" is recorded as "get". +func Test_MetricsContractMethodLabelIsRawRequestMethod(t *testing.T) { + methods := []string{http.MethodGet, http.MethodPost, http.MethodDelete, "get", "PaTcH", "CUSTOMVERB"} + + for _, method := range methods { + t.Run(method, func(t *testing.T) { + rec := &metCharRecorder{} + // No router: mux would reject the odd verbs before the middleware runs. + handler := metCharChain(rec, nil, metCharOKHandler()) + + metCharServe(t, handler, method, "/verb") + + call := rec.one(t) + require.Equal(t, metCharWant("/verb", method, "200"), call.labels) + require.Equal(t, method, call.labels[3], "method label is verbatim r.Method") + }) + } +} + +// --------------------------------------------------------------------------- +// 8. StatusResponseWriter wrapping / reuse. +// --------------------------------------------------------------------------- + +// Test_MetricsContractResponseWriterReuse pins the wrapping contract. +// +// - When the incoming ResponseWriter is ALREADY a *StatusResponseWriter +// (Logging ran first), Metrics reuses it — no double wrapping, and the +// inner handler sees the very same pointer. +// - Otherwise Metrics allocates one. Note that the code does NOT reassign +// the local `w` — but it passes `srw` to inner.ServeHTTP, so the inner +// handler DOES receive the wrapper (and Unwrap() returns the original +// writer). This test pins that, since the non-reassignment reads like a +// bug at a glance. +func Test_MetricsContractResponseWriterReuse(t *testing.T) { + t.Run("already wrapped is reused not double wrapped", func(t *testing.T) { + rec := &metCharRecorder{} + rr := httptest.NewRecorder() + outer := &StatusResponseWriter{ResponseWriter: rr} + + var seen http.ResponseWriter + + inner := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + seen = w + + w.WriteHeader(http.StatusAccepted) + }) + + handler := metCharChain(rec, nil, inner) + handler.ServeHTTP(outer, httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/w", http.NoBody)) + + require.Same(t, outer, seen, "existing *StatusResponseWriter must be reused") + require.Equal(t, http.StatusAccepted, outer.Status()) + require.Equal(t, metCharWant("/w", http.MethodGet, "202"), rec.one(t).labels) + }) + + t.Run("plain writer is wrapped and the wrapper reaches the inner handler", func(t *testing.T) { + rec := &metCharRecorder{} + rr := httptest.NewRecorder() + + var seen http.ResponseWriter + + inner := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + seen = w + + w.WriteHeader(http.StatusCreated) + }) + + handler := metCharChain(rec, nil, inner) + handler.ServeHTTP(rr, httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/w", http.NoBody)) + + srw, ok := seen.(*StatusResponseWriter) + require.True(t, ok, "inner handler receives the newly created *StatusResponseWriter") + require.NotSame(t, http.ResponseWriter(rr), seen) + require.Same(t, rr, srw.Unwrap(), "wrapper unwraps to the original writer") + require.Equal(t, http.StatusCreated, rr.Code, "status still reaches the real writer") + require.Equal(t, metCharWant("/w", http.MethodGet, "201"), rec.one(t).labels) + }) + + t.Run("graphql skip path also passes the wrapper through", func(t *testing.T) { + rec := &metCharRecorder{} + rr := httptest.NewRecorder() + + var seen http.ResponseWriter + + inner := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + seen = w + + w.WriteHeader(http.StatusOK) + }) + + handler := metCharChain(rec, nil, inner) + handler.ServeHTTP(rr, httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/graphql", http.NoBody)) + + require.IsType(t, &StatusResponseWriter{}, seen) + require.Empty(t, rec.all()) + }) +} + +// --------------------------------------------------------------------------- +// 9. Caching of route/status attributes. +// --------------------------------------------------------------------------- + +// Test_MetricsContractAttributeCacheKeying pins that the per-instance +// routeAttrs/statusAttrs caches are keyed correctly: repeated requests produce +// identical labels, and different methods on the SAME path produce different, +// correct labels (a path-only cache key would leak the first method). +func Test_MetricsContractAttributeCacheKeying(t *testing.T) { + rec := metCharNewAttrRecorder() + + handler := metCharChain(rec, func(r *mux.Router, h http.Handler) { + r.Handle("/users/{id}", h) + r.Handle("/orders/{id}", h) + }, metCharOKHandler()) + + metCharServe(t, handler, http.MethodGet, "/users/1") + metCharServe(t, handler, http.MethodGet, "/users/2") + metCharServe(t, handler, http.MethodPost, "/users/3") + metCharServe(t, handler, http.MethodGet, "/orders/9") + metCharServe(t, handler, http.MethodPost, "/users/4") + + calls := rec.all() + require.Len(t, calls, 5) + + want := [][]string{ + metCharWant("/users/{id}", http.MethodGet, "200"), + metCharWant("/users/{id}", http.MethodGet, "200"), + metCharWant("/users/{id}", http.MethodPost, "200"), + metCharWant("/orders/{id}", http.MethodGet, "200"), + metCharWant("/users/{id}", http.MethodPost, "200"), + } + + for i := range calls { + require.Equal(t, want[i], metCharLabelSet(t, &calls[i]), "call %d", i) + } +} + +// Test_MetricsContractStatusCacheKeying pins that the status attribute cache +// returns the right value per status code, including after a repeat. +func Test_MetricsContractStatusCacheKeying(t *testing.T) { + rec := metCharNewAttrRecorder() + + codes := []int{http.StatusOK, http.StatusNotFound, http.StatusOK, http.StatusInternalServerError, http.StatusNotFound} + + handler := metCharChain(rec, metCharRegister("/c/{code}"), + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + code, _ := strconv.Atoi(mux.Vars(r)["code"]) + + w.WriteHeader(code) + })) + + for _, code := range codes { + metCharServe(t, handler, http.MethodGet, "/c/"+strconv.Itoa(code)) + } + + calls := rec.all() + require.Len(t, calls, len(codes)) + + for i := range calls { + require.Equal(t, metCharWant("/c/{code}", http.MethodGet, strconv.Itoa(codes[i])), + metCharLabelSet(t, &calls[i])) + } +} + +// --------------------------------------------------------------------------- +// 10. Concurrency. +// --------------------------------------------------------------------------- + +// Test_MetricsContractConcurrentRequests fires many concurrent requests through +// a SINGLE Metrics instance across several routes, methods and statuses and +// asserts the recorded multiset of label sets is exactly what is expected. Run +// under -race this also covers the sync.Map caches. +func Test_MetricsContractConcurrentRequests(t *testing.T) { + const iterations = 40 + + rec := metCharNewAttrRecorder() + + handler := metCharChain(rec, func(r *mux.Router, h http.Handler) { + r.Handle("/users/{id}", h) + r.Handle("/orders/{id}", h) + r.Handle("/assets/{name}", h) + }, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("fail") == "1" { + w.WriteHeader(http.StatusInternalServerError) + + return + } + + w.WriteHeader(http.StatusOK) + })) + + type reqSpec struct { + method string + target string + want []string + } + + specs := []reqSpec{ + {http.MethodGet, "/users/1", metCharWant("/users/{id}", http.MethodGet, "200")}, + {http.MethodPost, "/users/2", metCharWant("/users/{id}", http.MethodPost, "200")}, + {http.MethodGet, "/orders/3?fail=1", metCharWant("/orders/{id}", http.MethodGet, "500")}, + {http.MethodGet, "/assets/logo.png", metCharWant("/assets/logo.png", http.MethodGet, "200")}, + } + + var wg sync.WaitGroup + + for range iterations { + for _, spec := range specs { + wg.Add(1) + + go func(spec reqSpec) { + defer wg.Done() + + rr := httptest.NewRecorder() + req := httptest.NewRequestWithContext(t.Context(), spec.method, spec.target, http.NoBody) + + handler.ServeHTTP(rr, req) + }(spec) + } + } + + wg.Wait() + + got := make(map[string]int, len(specs)) + + concurrentCalls := rec.all() + + for i := range concurrentCalls { + require.Equal(t, metCharMetricName, concurrentCalls[i].name) + require.Equal(t, metCharKindAttrs, concurrentCalls[i].kind) + + got[strings.Join(metCharLabelSet(t, &concurrentCalls[i]), "|")]++ + } + + want := make(map[string]int, len(specs)) + for _, spec := range specs { + want[strings.Join(spec.want, "|")] = iterations + } + + require.Equal(t, want, got, "exact multiset of recorded label sets") +} + +// Test_MetricsContractConcurrentSlowPath repeats the concurrency check for an +// implementation that only satisfies `metrics`, so the slow varargs path is +// exercised under -race as well. +func Test_MetricsContractConcurrentSlowPath(t *testing.T) { + const iterations = 30 + + rec := &metCharRecorder{} + handler := metCharChain(rec, metCharRegister("/users/{id}"), metCharOKHandler()) + + var wg sync.WaitGroup + + for range iterations { + wg.Add(1) + + go func() { + defer wg.Done() + + metCharServe(t, handler, http.MethodGet, "/users/1") + }() + } + + wg.Wait() + + calls := rec.all() + require.Len(t, calls, iterations) + + for _, c := range calls { + require.Equal(t, metCharWant("/users/{id}", http.MethodGet, "200"), c.labels) + } +} diff --git a/pkg/gofr/http/middleware/tracer.go b/pkg/gofr/http/middleware/tracer.go index f97062259c..ca6a7ee115 100644 --- a/pkg/gofr/http/middleware/tracer.go +++ b/pkg/gofr/http/middleware/tracer.go @@ -88,10 +88,13 @@ func Tracer(inner http.Handler) http.Handler { // http.response.status_code is set after the handler returns via // the StatusResponseWriter wrap shared with Logging. - // Use the StatusResponseWriter wrap (provided by the Logging - // middleware) to capture the response status; type assert on the - // way out. If we are not after Logging in the chain — uncommon — - // fall back to wrapping locally. + // Capture the response status via a StatusResponseWriter. Reuse an + // existing wrap if some outer middleware already provided one; + // otherwise wrap locally. + // + // In GoFr's default chain Tracer is registered FIRST, i.e. outermost, + // so nothing has wrapped w yet and the local wrap is what actually + // happens on every request — Logging then wraps this one in turn. srw, ok := w.(*StatusResponseWriter) if !ok { srw = &StatusResponseWriter{ResponseWriter: w} diff --git a/pkg/gofr/http/middleware/tracer_test.go b/pkg/gofr/http/middleware/tracer_test.go index b7dc9fd2e8..4860823465 100644 --- a/pkg/gofr/http/middleware/tracer_test.go +++ b/pkg/gofr/http/middleware/tracer_test.go @@ -4,6 +4,8 @@ import ( "context" "net/http" "net/http/httptest" + "sort" + "strings" "testing" "github.com/gorilla/mux" @@ -12,10 +14,13 @@ import ( "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/baggage" + "go.opentelemetry.io/otel/codes" "go.opentelemetry.io/otel/propagation" "go.opentelemetry.io/otel/sdk/trace" "go.opentelemetry.io/otel/sdk/trace/tracetest" otelTrace "go.opentelemetry.io/otel/trace" + + "gofr.dev/pkg/gofr/version" ) // W3C TraceContext fixture values reused across the propagation tests. @@ -310,3 +315,686 @@ func TestTracer_EmitsOTelHTTPSemconvAttributes(t *testing.T) { assert.Equal(t, "/users/{id}", attrs[attribute.Key("http.route")].AsString()) assert.Equal(t, int64(http.StatusCreated), attrs[attribute.Key("http.response.status_code")].AsInt64()) } + +// --------------------------------------------------------------------------- +// Characterization suite for the Tracer HTTP middleware. +// +// Every identifier below is prefixed with `tracerChar` so this file can be +// merged with sibling _test.go additions in the same package without +// collisions. Nothing here is aspirational: every assertion pins the CURRENT +// behavior of pkg/gofr/http/middleware/tracer.go as observed against the +// unmodified source. +// --------------------------------------------------------------------------- + +// tracerCharScopeName returns the exact instrumentation-scope (tracer) name +// the middleware resolves at chain-build time. +func tracerCharScopeName() string { return "gofr-" + version.Framework } + +// tracerCharInstallRecordingTP installs an sdktrace provider with an in-memory +// span recorder and restores the previously installed global provider on +// cleanup. The provider MUST be installed before Tracer() is called because +// Tracer resolves otel.Tracer(...) once at chain-build time. +func tracerCharInstallRecordingTP(t *testing.T) *tracetest.SpanRecorder { + t.Helper() + + prev := otel.GetTracerProvider() + rec := tracetest.NewSpanRecorder() + tp := trace.NewTracerProvider(trace.WithSpanProcessor(rec)) + + otel.SetTracerProvider(tp) + + t.Cleanup(func() { + _ = tp.Shutdown(context.Background()) + + otel.SetTracerProvider(prev) + }) + + return rec +} + +// tracerCharInstallNeverSampleTP installs exactly the provider GoFr's +// initTracer builds when no exporter is configured (otel.go), plus a recorder +// so tests can prove nothing is ever exported. +func tracerCharInstallNeverSampleTP(t *testing.T) *tracetest.SpanRecorder { + t.Helper() + + prev := otel.GetTracerProvider() + rec := tracetest.NewSpanRecorder() + tp := trace.NewTracerProvider( + trace.WithSampler(trace.NeverSample()), + trace.WithSpanProcessor(rec), + ) + + otel.SetTracerProvider(tp) + + t.Cleanup(func() { + _ = tp.Shutdown(context.Background()) + + otel.SetTracerProvider(prev) + }) + + return rec +} + +// tracerCharAttrs returns the span's attributes sorted by key so an exact +// expected slice can be compared — an added, renamed or retyped attribute +// then fails the comparison. +func tracerCharAttrs(s trace.ReadOnlySpan) []attribute.KeyValue { + attrs := s.Attributes() + out := make([]attribute.KeyValue, len(attrs)) + copy(out, attrs) + + sort.Slice(out, func(i, j int) bool { return out[i].Key < out[j].Key }) + + return out +} + +// tracerCharLogger captures the RequestLog values emitted by the Logging +// middleware. +type tracerCharLogger struct { + logs []*RequestLog + errors []*RequestLog +} + +func (l *tracerCharLogger) Log(args ...any) { + if rl, ok := args[0].(*RequestLog); ok { + l.logs = append(l.logs, rl) + } +} + +func (l *tracerCharLogger) Error(args ...any) { + if rl, ok := args[0].(*RequestLog); ok { + l.errors = append(l.errors, rl) + } +} + +// last returns the single RequestLog captured on either channel. +func (l *tracerCharLogger) last() *RequestLog { + if len(l.errors) > 0 { + return l.errors[len(l.errors)-1] + } + + if len(l.logs) > 0 { + return l.logs[len(l.logs)-1] + } + + return nil +} + +// Test_TracerContract_InstrumentationScopeName pins the exact tracer name. +func Test_TracerContract_InstrumentationScopeName(t *testing.T) { + rec := tracerCharInstallRecordingTP(t) + + handler := Tracer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) + req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/scope", http.NoBody) + + handler.ServeHTTP(httptest.NewRecorder(), req) + + spans := rec.Ended() + require.Len(t, spans, 1) + + assert.Equal(t, "gofr-dev", tracerCharScopeName(), + "version.Framework changed; the scope name below moves with it") + assert.Equal(t, tracerCharScopeName(), spans[0].InstrumentationScope().Name) + assert.Empty(t, spans[0].InstrumentationScope().Version, + "middleware passes no WithInstrumentationVersion") + assert.Empty(t, spans[0].InstrumentationScope().SchemaURL) +} + +// Test_TracerContract_SpanName pins "METHOD /route-template" across the mux +// route-resolution paths. +func Test_TracerContract_SpanName(t *testing.T) { + tests := []struct { + name string + build func(h http.Handler) http.Handler + // method/target of the inbound request. + method string + target string + want string + }{ + { + name: "mux matched route uses path template", + build: func(h http.Handler) http.Handler { + r := mux.NewRouter() + r.Handle("/users/{id}", h).Methods(http.MethodGet) + + return r + }, + method: http.MethodGet, + target: "/users/42", + want: "GET /users/{id}", + }, + { + name: "mux PathPrefix-only route still yields a template", + build: func(h http.Handler) http.Handler { + r := mux.NewRouter() + r.PathPrefix("/static").Handler(h) + + return r + }, + method: http.MethodGet, + target: "/static/css/app.css", + want: "GET /static", + }, + { + name: "mux route without any path matcher falls back to URL.Path", + build: func(h http.Handler) http.Handler { + r := mux.NewRouter() + r.Methods(http.MethodGet).Handler(h) + + return r + }, + method: http.MethodGet, + target: "/no/path/matcher", + want: "GET /no/path/matcher", + }, + { + name: "unmatched route (404 handler, CurrentRoute nil) falls back to URL.Path", + build: func(h http.Handler) http.Handler { + r := mux.NewRouter() + r.Handle("/known", http.NotFoundHandler()) + r.NotFoundHandler = h + + return r + }, + method: http.MethodGet, + target: "/definitely/unknown", + want: "GET /definitely/unknown", + }, + { + name: "lowercase inbound method is upper-cased", + build: func(h http.Handler) http.Handler { return h }, + method: "get", + target: "/lower", + want: "GET /lower", + }, + { + name: "standalone handler (no mux) uses URL.Path", + build: func(h http.Handler) http.Handler { return h }, + method: http.MethodDelete, + target: "/standalone", + want: "DELETE /standalone", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + rec := tracerCharInstallRecordingTP(t) + + srv := tc.build(Tracer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))) + req := httptest.NewRequestWithContext(t.Context(), tc.method, tc.target, http.NoBody) + + srv.ServeHTTP(httptest.NewRecorder(), req) + + spans := rec.Ended() + require.Len(t, spans, 1) + assert.Equal(t, tc.want, spans[0].Name()) + // http.route always equals the route portion of the span name. + assert.Equal(t, strings.TrimPrefix(tc.want, strings.ToUpper(tc.method)+" "), + tracerCharAttrs(spans[0])[2].Value.AsString()) + }) + } +} + +// Test_TracerContract_ExactAttributeSet pins the complete attribute set — +// keys, values AND value types — for a matched route. +func Test_TracerContract_ExactAttributeSet(t *testing.T) { + rec := tracerCharInstallRecordingTP(t) + + router := mux.NewRouter() + router.Handle("/users/{id}", Tracer(http.HandlerFunc( + func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusCreated) }, + ))).Methods(http.MethodGet) + + req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/users/42", http.NoBody) + router.ServeHTTP(httptest.NewRecorder(), req) + + spans := rec.Ended() + require.Len(t, spans, 1) + + want := []attribute.KeyValue{ + attribute.String("http.request.method", "GET"), + attribute.Int("http.response.status_code", http.StatusCreated), + attribute.String("http.route", "/users/{id}"), + } + + got := tracerCharAttrs(spans[0]) + assert.Equal(t, want, got, "complete attribute set (sorted by key) changed") + + // Pin the value TYPES explicitly so an int->string retype fails loudly. + assert.Equal(t, attribute.STRING, got[0].Value.Type()) + assert.Equal(t, attribute.INT64, got[1].Value.Type()) + assert.Equal(t, attribute.STRING, got[2].Value.Type()) + assert.Equal(t, int64(http.StatusCreated), got[1].Value.AsInt64()) +} + +// Test_TracerContract_StatusCodeAttribute pins http.response.status_code +// across the ways a handler can (not) set a status. +func Test_TracerContract_StatusCodeAttribute(t *testing.T) { + tests := []struct { + name string + handler http.HandlerFunc + want int64 + }{ + { + name: "explicit WriteHeader 404", + handler: func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNotFound) }, + want: http.StatusNotFound, + }, + { + name: "explicit WriteHeader 500", + handler: func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusInternalServerError) }, + want: http.StatusInternalServerError, + }, + { + name: "body only, implicit 200 via StatusResponseWriter.Write", + handler: func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("hello")) }, + want: http.StatusOK, + }, + { + name: "handler does nothing, Status() normalizes 0 to 200", + handler: func(http.ResponseWriter, *http.Request) {}, + want: http.StatusOK, + }, + { + name: "WriteHeader then Write keeps the explicit status", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusAccepted) + _, _ = w.Write([]byte("ok")) + }, + want: http.StatusAccepted, + }, + { + name: "duplicate WriteHeader keeps the first status", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusTeapot) + w.WriteHeader(http.StatusInternalServerError) + }, + want: http.StatusTeapot, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + rec := tracerCharInstallRecordingTP(t) + + handler := Tracer(tc.handler) + req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/status", http.NoBody) + + handler.ServeHTTP(httptest.NewRecorder(), req) + + spans := rec.Ended() + require.Len(t, spans, 1) + + attrs := tracerCharAttrs(spans[0]) + require.Len(t, attrs, 3) + assert.Equal(t, attribute.Key("http.response.status_code"), attrs[1].Key) + assert.Equal(t, tc.want, attrs[1].Value.AsInt64()) + }) + } +} + +// Test_TracerContract_SpanKindStatusAndEvents pins that the middleware emits +// an Internal span with an Unset status and no events — even for a 5xx. +func Test_TracerContract_SpanKindStatusAndEvents(t *testing.T) { + for _, status := range []int{http.StatusOK, http.StatusInternalServerError} { + t.Run(http.StatusText(status), func(t *testing.T) { + rec := tracerCharInstallRecordingTP(t) + + handler := Tracer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(status) + })) + req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/kind", http.NoBody) + + handler.ServeHTTP(httptest.NewRecorder(), req) + + spans := rec.Ended() + require.Len(t, spans, 1) + + got := spans[0] + assert.Equal(t, otelTrace.SpanKindInternal, got.SpanKind(), + "middleware never calls trace.WithSpanKind, so the SDK default (Internal) applies") + assert.Equal(t, codes.Unset, got.Status().Code, "no SetStatus call anywhere in the middleware") + assert.Empty(t, got.Status().Description) + assert.Empty(t, got.Events(), "middleware records no events (no RecordError)") + assert.Empty(t, got.Links()) + assert.True(t, got.EndTime().After(got.StartTime()) || got.EndTime().Equal(got.StartTime())) + }) + } +} + +// Test_TracerContract_ReusesStatusResponseWriter pins the ResponseWriter +// wrapping behavior in all three chain arrangements. +func Test_TracerContract_ReusesStatusResponseWriter(t *testing.T) { + t.Run("chained after Logging it reuses the existing StatusResponseWriter", func(t *testing.T) { + tracerCharInstallRecordingTP(t) + + var fromLogging, inHandler http.ResponseWriter + + capture := func(inner http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fromLogging = w + inner.ServeHTTP(w, r) + }) + } + + handler := Logging(LogProbes{}, &tracerCharLogger{})( + capture(Tracer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + inHandler = w + })))) + + req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/reuse", http.NoBody) + handler.ServeHTTP(httptest.NewRecorder(), req) + + loggingSRW, ok := fromLogging.(*StatusResponseWriter) + require.True(t, ok, "Logging must hand a *StatusResponseWriter to the next middleware") + + handlerSRW, ok := inHandler.(*StatusResponseWriter) + require.True(t, ok) + assert.Same(t, loggingSRW, handlerSRW, "Tracer must not double-wrap after Logging") + }) + + t.Run("standalone it wraps locally exactly once", func(t *testing.T) { + tracerCharInstallRecordingTP(t) + + var inHandler http.ResponseWriter + + handler := Tracer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + inHandler = w + })) + + rr := httptest.NewRecorder() + req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/wrap", http.NoBody) + handler.ServeHTTP(rr, req) + + srw, ok := inHandler.(*StatusResponseWriter) + require.True(t, ok, "Tracer must wrap when it is not preceded by Logging") + assert.NotSame(t, http.ResponseWriter(rr), inHandler) + assert.Same(t, rr, srw.Unwrap(), "exactly one layer of wrapping") + }) + + t.Run("production order (Tracer outer, Logging inner) double-wraps", func(t *testing.T) { + tracerCharInstallRecordingTP(t) + + var layers []http.ResponseWriter + + // The unwrap chain must be read INSIDE the handler: Logging returns its + // StatusResponseWriter to a sync.Pool on the way out and nils the + // embedded ResponseWriter, so the chain is unreadable afterwards. + handler := Tracer(Logging(LogProbes{}, &tracerCharLogger{})( + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + for cur := w; cur != nil; { + layers = append(layers, cur) + + srw, ok := cur.(*StatusResponseWriter) + if !ok { + break + } + + cur = srw.Unwrap() + } + }))) + + rr := httptest.NewRecorder() + req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/double", http.NoBody) + handler.ServeHTTP(rr, req) + + // Production wires r.Use(Tracer, Logging, ...), so Tracer is the OUTER + // middleware: its type assertion always fails, it wraps locally, and + // Logging then wraps that wrapper again. Two StatusResponseWriter + // layers are therefore live on every production request — which is what + // tracer.go's comment now describes. + require.Len(t, layers, 3, "expected raw recorder wrapped by two StatusResponseWriters") + assert.IsType(t, &StatusResponseWriter{}, layers[0]) + assert.IsType(t, &StatusResponseWriter{}, layers[1]) + assert.Same(t, rr, layers[2]) + }) +} + +// Test_TracerContract_InboundTraceparent pins W3C trace-context continuation. +func Test_TracerContract_InboundTraceparent(t *testing.T) { + installPropagators(t) + + rec := tracerCharInstallRecordingTP(t) + + var handlerBaggage baggage.Baggage + + handler := Tracer(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { + handlerBaggage = baggage.FromContext(r.Context()) + })) + + req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/inbound", http.NoBody) + req.Header.Set("Traceparent", "00-"+w3cFixtureTraceID+"-"+w3cFixtureParentSpan+"-01") + req.Header.Set("Baggage", "tenant=acme,region=us-east") + + handler.ServeHTTP(httptest.NewRecorder(), req) + + spans := rec.Ended() + require.Len(t, spans, 1) + + got := spans[0] + + // Trace ID is inherited verbatim from the fixture header. + assert.Equal(t, w3cFixtureTraceID, got.SpanContext().TraceID().String()) + // The span's own ID is fresh and non-deterministic — pin validity/shape only. + assert.True(t, got.SpanContext().SpanID().IsValid()) + assert.Len(t, got.SpanContext().SpanID().String(), 16) + assert.NotEqual(t, w3cFixtureParentSpan, got.SpanContext().SpanID().String()) + + assert.Equal(t, w3cFixtureParentSpan, got.Parent().SpanID().String()) + assert.Equal(t, w3cFixtureTraceID, got.Parent().TraceID().String()) + assert.True(t, got.Parent().IsRemote(), "parent must be marked remote") + assert.True(t, got.Parent().IsSampled()) + + require.Equal(t, 2, handlerBaggage.Len(), "baggage must survive into the handler context") + assert.Equal(t, "acme", handlerBaggage.Member("tenant").Value()) + assert.Equal(t, "us-east", handlerBaggage.Member("region").Value()) +} + +// Test_TracerContract_NoInboundTraceparentIsRoot pins that a request without a +// traceparent produces a fresh root span. +func Test_TracerContract_NoInboundTraceparentIsRoot(t *testing.T) { + installPropagators(t) + + rec := tracerCharInstallRecordingTP(t) + + handler := Tracer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) + req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/root", http.NoBody) + + handler.ServeHTTP(httptest.NewRecorder(), req) + + spans := rec.Ended() + require.Len(t, spans, 1) + + got := spans[0] + assert.False(t, got.Parent().IsValid(), "expected a root span") + assert.Equal(t, zeroTraceID, got.Parent().TraceID().String()) + assert.True(t, got.SpanContext().TraceID().IsValid()) + assert.Len(t, got.SpanContext().TraceID().String(), 32) + assert.NotEqual(t, zeroTraceID, got.SpanContext().TraceID().String()) +} + +// Test_TracerContract_NeverSampleKeepsValidIDs is the key regression guard for +// GoFr's default (no exporter configured) deployment: initTracer installs an +// sdktrace provider with NeverSample — NOT a noop provider — precisely so the +// span context handed to handlers still carries a valid TraceID/SpanID that +// X-Correlation-ID and the trace_id log field can use. +func Test_TracerContract_NeverSampleKeepsValidIDs(t *testing.T) { + installPropagators(t) + + rec := tracerCharInstallNeverSampleTP(t) + + var ( + sc otelTrace.SpanContext + recording bool + ) + + handler := Tracer(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { + span := otelTrace.SpanFromContext(r.Context()) + sc = span.SpanContext() + recording = span.IsRecording() + })) + + req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/never", http.NoBody) + handler.ServeHTTP(httptest.NewRecorder(), req) + + require.True(t, sc.IsValid(), "NeverSample must still yield a valid span context") + assert.True(t, sc.TraceID().IsValid()) + assert.True(t, sc.SpanID().IsValid()) + assert.NotEqual(t, zeroTraceID, sc.TraceID().String()) + assert.NotEqual(t, zeroSpanID, sc.SpanID().String()) + assert.Len(t, sc.TraceID().String(), 32) + assert.Len(t, sc.SpanID().String(), 16) + assert.False(t, sc.IsSampled(), "NeverSample must clear the sampled flag") + assert.False(t, recording, "a dropped span must not be recording") + assert.Empty(t, rec.Ended(), "nothing may be exported under NeverSample") +} + +// Test_TracerContract_NeverSampleWithInboundTraceparent pins that NeverSample +// is NOT parent-based: an inbound sampled=01 traceparent still gets dropped, +// though the trace ID is inherited so the trace stays correlatable. +func Test_TracerContract_NeverSampleWithInboundTraceparent(t *testing.T) { + installPropagators(t) + + rec := tracerCharInstallNeverSampleTP(t) + + var sc otelTrace.SpanContext + + handler := Tracer(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { + sc = otelTrace.SpanFromContext(r.Context()).SpanContext() + })) + + req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/never-parent", http.NoBody) + req.Header.Set("Traceparent", "00-"+w3cFixtureTraceID+"-"+w3cFixtureParentSpan+"-01") + + handler.ServeHTTP(httptest.NewRecorder(), req) + + require.True(t, sc.IsValid()) + assert.Equal(t, w3cFixtureTraceID, sc.TraceID().String()) + assert.False(t, sc.IsSampled()) + assert.Empty(t, rec.Ended()) +} + +// tracerCharChain builds the two possible orderings of Logging and Tracer. +func tracerCharChain(loggingOuter bool, lg logger, h http.Handler) http.Handler { + logging := Logging(LogProbes{}, lg) + + if loggingOuter { + return logging(Tracer(h)) + } + + return Tracer(logging(h)) +} + +// Test_TracerContract_CorrelationIDWithLogging characterizes the interaction +// between Tracer and the Logging middleware's X-Correlation-ID header and +// trace_id/span_id log fields, for BOTH chain orders and BOTH provider +// configurations. +// +// FINDING: production (pkg/gofr/http_server.go) registers +// r.Use(middleware.Tracer, middleware.Logging(...)) — with gorilla/mux the +// FIRST registered middleware is the OUTERMOST, so production runs +// Tracer-outer / Logging-inner. That is the order in which Logging observes +// the span Tracer just started, and the correlation ID is real. The +// Logging-outer order (which reads the span context BEFORE Tracer starts a +// span) yields the all-zeros constants instead. +func Test_TracerContract_CorrelationIDWithLogging(t *testing.T) { + tests := []struct { + name string + loggingOuter bool + neverSample bool + traceparent bool + wantZeroIDs bool + }{ + {name: "production order, recording provider", wantZeroIDs: false}, + {name: "production order, NeverSample provider", neverSample: true, wantZeroIDs: false}, + {name: "production order, inbound traceparent", traceparent: true, wantZeroIDs: false}, + {name: "logging outer, recording provider", loggingOuter: true, wantZeroIDs: true}, + {name: "logging outer, NeverSample provider", loggingOuter: true, neverSample: true, wantZeroIDs: true}, + {name: "logging outer, inbound traceparent", loggingOuter: true, traceparent: true, wantZeroIDs: true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + installPropagators(t) + + if tc.neverSample { + tracerCharInstallNeverSampleTP(t) + } else { + tracerCharInstallRecordingTP(t) + } + + lg := &tracerCharLogger{} + + var inHandler otelTrace.SpanContext + + handler := tracerCharChain(tc.loggingOuter, lg, + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + inHandler = otelTrace.SpanFromContext(r.Context()).SpanContext() + _, _ = w.Write([]byte("ok")) + })) + + rr := httptest.NewRecorder() + req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/corr", http.NoBody) + + if tc.traceparent { + req.Header.Set("Traceparent", "00-"+w3cFixtureTraceID+"-"+w3cFixtureParentSpan+"-01") + } + + handler.ServeHTTP(rr, req) + + rl := lg.last() + require.NotNil(t, rl, "Logging must emit a RequestLog") + assert.Equal(t, http.StatusOK, rl.Response) + + corr := rr.Header().Get("X-Correlation-ID") + assert.Equal(t, rl.TraceID, corr, "header and log field always agree") + + if tc.wantZeroIDs { + assert.Equal(t, zeroTraceID, rl.TraceID, + "Logging read the span context before Tracer started a span") + assert.Equal(t, zeroSpanID, rl.SpanID) + + return + } + + require.True(t, inHandler.IsValid()) + assert.Equal(t, inHandler.TraceID().String(), rl.TraceID, + "log trace_id must equal the span Tracer started") + assert.Equal(t, inHandler.SpanID().String(), rl.SpanID) + assert.Len(t, rl.TraceID, 32) + assert.Len(t, rl.SpanID, 16) + + if tc.traceparent { + assert.Equal(t, w3cFixtureTraceID, rl.TraceID, + "inbound traceparent must surface as the correlation ID") + } + }) + } +} + +// Test_TracerContract_StatusFlowsThroughLoggingChain pins that the span's +// http.response.status_code is correct in the production chain order, where +// Tracer's own StatusResponseWriter sits outside Logging's. +func Test_TracerContract_StatusFlowsThroughLoggingChain(t *testing.T) { + rec := tracerCharInstallRecordingTP(t) + + lg := &tracerCharLogger{} + handler := Tracer(Logging(LogProbes{}, lg)( + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusServiceUnavailable) + }))) + + req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/chain", http.NoBody) + handler.ServeHTTP(httptest.NewRecorder(), req) + + spans := rec.Ended() + require.Len(t, spans, 1) + + attrs := tracerCharAttrs(spans[0]) + assert.Equal(t, int64(http.StatusServiceUnavailable), attrs[1].Value.AsInt64()) + assert.Equal(t, codes.Unset, spans[0].Status().Code, "5xx does not mark the span as errored") + + require.Len(t, lg.errors, 1, "5xx is logged via Error") + assert.Equal(t, http.StatusServiceUnavailable, lg.errors[0].Response) +} diff --git a/pkg/gofr/http/middleware/web_socket_test.go b/pkg/gofr/http/middleware/web_socket_test.go index 0e65a79094..19769bf603 100644 --- a/pkg/gofr/http/middleware/web_socket_test.go +++ b/pkg/gofr/http/middleware/web_socket_test.go @@ -82,3 +82,175 @@ func Test_WSConnectionCreate_Success(t *testing.T) { assert.Equal(t, http.StatusOK, rec.Code) } + +// --------------------------------------------------------------------------- +// Characterization suite for WSHandlerUpgrade. +// +// Pins the observable behavior of the middleware for both branches: a +// non-upgrade request must pass through completely untouched, and an upgrade +// request must be handed to the upgrader with the resulting connection +// registered and its key placed in the request context. +// --------------------------------------------------------------------------- + +// TestWSHandlerUpgrade_Char_NonUpgradePassesThrough pins that a request without +// websocket upgrade headers reaches the inner handler unmodified: no upgrade is +// attempted, the context carries no connection key, no connection is +// registered, and the inner handler's status/headers/body are what the client +// sees. +func TestWSHandlerUpgrade_Char_NonUpgradePassesThrough(t *testing.T) { + tests := []struct { + name string + headers map[string]string + }{ + {"no-headers", nil}, + {"connection-only", map[string]string{"Connection": "upgrade"}}, + {"upgrade-only", map[string]string{"Upgrade": "websocket"}}, + {"wrong-upgrade-protocol", map[string]string{"Connection": "upgrade", "Upgrade": "h2c"}}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // No EXPECT() is registered on the mock upgrader: gomock fails the + // test if Upgrade is called at all on this path. + _, wsManager := initializeWebSocketMocks(t) + mockContainer, _ := container.NewMockContainer(t) + + var ( + called bool + gotCtxVal any + gotMethod string + gotPath string + ) + + inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + called = true + gotCtxVal = r.Context().Value(gofrWebSocket.WSConnectionKey) + gotMethod = r.Method + gotPath = r.URL.Path + + w.Header().Set("X-Inner", "yes") + w.WriteHeader(http.StatusTeapot) + _, _ = w.Write([]byte("inner body")) + }) + + req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/plain?a=b", http.NoBody) + for k, v := range tc.headers { + req.Header.Set(k, v) + } + + rec := httptest.NewRecorder() + + WSHandlerUpgrade(mockContainer, wsManager)(inner).ServeHTTP(rec, req) + + assert.True(t, called, "inner handler must be invoked") + assert.Nil(t, gotCtxVal, "no websocket key must be added to the context") + assert.Equal(t, http.MethodPost, gotMethod) + assert.Equal(t, "/plain", gotPath) + + // The inner handler's response is passed through byte for byte. + assert.Equal(t, http.StatusTeapot, rec.Code) + assert.Equal(t, "yes", rec.Header().Get("X-Inner")) + assert.Equal(t, "inner body", rec.Body.String()) + + assert.Empty(t, wsManager.ListConnections(), "no connection must be registered") + }) + } +} + +// TestWSHandlerUpgrade_Char_UpgradeFailure pins the exact response written when +// the upgrader fails: 400 with net/http's plain-text error envelope, and the +// inner handler is NOT invoked. +func TestWSHandlerUpgrade_Char_UpgradeFailure(t *testing.T) { + mockUpgrader, wsManager := initializeWebSocketMocks(t) + mockContainer, _ := container.NewMockContainer(t) + + mockUpgrader.EXPECT().Upgrade(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, errConnection).Times(1) + + var called bool + + inner := http.HandlerFunc(func(http.ResponseWriter, *http.Request) { called = true }) + + req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/ws", http.NoBody) + req.Header.Set("Connection", "Upgrade") + req.Header.Set("Upgrade", "websocket") + + rec := httptest.NewRecorder() + + WSHandlerUpgrade(mockContainer, wsManager)(inner).ServeHTTP(rec, req) + + assert.False(t, called, "inner handler must not run after a failed upgrade") + assert.Equal(t, http.StatusBadRequest, rec.Code) + // http.Error's exact wire shape: plain text, sniffing disabled, trailing \n. + assert.Equal(t, "text/plain; charset=utf-8", rec.Header().Get("Content-Type")) + assert.Equal(t, "nosniff", rec.Header().Get("X-Content-Type-Options")) + assert.Equal(t, "Could not open WebSocket connection\n", rec.Body.String()) + + assert.Empty(t, wsManager.ListConnections()) +} + +// TestWSHandlerUpgrade_Char_UpgradeSuccess pins the success branch: the +// connection is registered under the Sec-WebSocket-Key, that key is placed in +// the request context, and the inner handler still runs and owns the response. +func TestWSHandlerUpgrade_Char_UpgradeSuccess(t *testing.T) { + mockUpgrader, wsManager := initializeWebSocketMocks(t) + mockContainer, _ := container.NewMockContainer(t) + + conn := &websocket.Conn{} + mockUpgrader.EXPECT().Upgrade(gomock.Any(), gomock.Any(), gomock.Any()).Return(conn, nil).Times(1) + + const key = "dGhlIHNhbXBsZSBub25jZQ==" + + var gotCtxVal any + + inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotCtxVal = r.Context().Value(gofrWebSocket.WSConnectionKey) + + w.WriteHeader(http.StatusSwitchingProtocols) + }) + + req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/ws", http.NoBody) + req.Header.Set("Connection", "Upgrade") + req.Header.Set("Upgrade", "websocket") + req.Header.Set("Sec-WebSocket-Key", key) + + rec := httptest.NewRecorder() + + WSHandlerUpgrade(mockContainer, wsManager)(inner).ServeHTTP(rec, req) + + assert.Equal(t, key, gotCtxVal, "the Sec-WebSocket-Key must be in the inner request's context") + assert.Equal(t, []string{key}, wsManager.ListConnections()) + + registered := wsManager.GetWebsocketConnection(key) + if assert.NotNil(t, registered) { + assert.Equal(t, conn, registered.Conn) + } + + assert.Equal(t, http.StatusSwitchingProtocols, rec.Code) +} + +// TestWSHandlerUpgrade_Char_MissingSecWebSocketKey pins that a successful +// upgrade without a Sec-WebSocket-Key registers the connection under the EMPTY +// string — so two such connections would overwrite one another. Reported, not +// fixed. +func TestWSHandlerUpgrade_Char_MissingSecWebSocketKey(t *testing.T) { + mockUpgrader, wsManager := initializeWebSocketMocks(t) + mockContainer, _ := container.NewMockContainer(t) + + mockUpgrader.EXPECT().Upgrade(gomock.Any(), gomock.Any(), gomock.Any()).Return(&websocket.Conn{}, nil).Times(1) + + var gotCtxVal any + + inner := http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { + gotCtxVal = r.Context().Value(gofrWebSocket.WSConnectionKey) + }) + + req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/ws", http.NoBody) + req.Header.Set("Connection", "Upgrade") + req.Header.Set("Upgrade", "websocket") + + WSHandlerUpgrade(mockContainer, wsManager)(inner).ServeHTTP(httptest.NewRecorder(), req) + + assert.NotNil(t, gotCtxVal, "the key is present in the context, it is just empty") + assert.Empty(t, gotCtxVal) + assert.Equal(t, []string{""}, wsManager.ListConnections()) +} diff --git a/pkg/gofr/http/request.go b/pkg/gofr/http/request.go index a885aa20a6..d59a090109 100644 --- a/pkg/gofr/http/request.go +++ b/pkg/gofr/http/request.go @@ -7,6 +7,7 @@ import ( "errors" "fmt" "io" + "mime" "net/http" "reflect" "strings" @@ -18,6 +19,9 @@ const ( defaultMaxMemory = 32 << 20 // 32 MB contentTypeJSON = "application/json" + + contentTypeBinary = "binary/octet-stream" + contentTypeOctetStream = "application/octet-stream" ) var ( @@ -58,8 +62,14 @@ func (r *Request) PathParam(key string) string { // Bind parses the request body and binds it to the provided interface. func (r *Request) Bind(i any) error { - v := r.req.Header.Get("Content-Type") - contentType := strings.Split(v, ";")[0] + // Binding into a non-pointer would unmarshal into a throwaway copy and leave + // the caller's value untouched, so reject it up front instead of silently + // doing nothing. + if rv := reflect.ValueOf(i); rv.Kind() != reflect.Pointer { + return errNonPointerBind + } + + contentType := mediaType(r.req.Header.Get("Content-Type")) switch contentType { case contentTypeJSON: @@ -73,13 +83,43 @@ func (r *Request) Bind(i any) error { return r.bindMultipart(i) case "application/x-www-form-urlencoded": return r.bindFormURLEncoded(i) - case "binary/octet-stream": + // "binary/octet-stream" is GoFr's own spelling, kept for compatibility; + // "application/octet-stream" is the RFC 2046 one and is what clients + // actually send. Before Bind reported unsupported types, the RFC spelling + // fell through to a silent no-op; rejecting it instead of decoding it would + // have turned a naming gap into a hard error for the commoner spelling. + case contentTypeBinary, contentTypeOctetStream: return r.bindBinary(i) } + // An unrecognized media type is a no-op: the target is left as it was and no + // error is reported. + // + // This silently discards a body the caller probably meant to bind, and an + // earlier revision of this PR rejected it with 415 for exactly that reason. + // That is a breaking change and it is not a quiet one: fetch(url, {method: + // "POST", body: str}) with no headers sends text/plain, so a handler that + // returns the Bind error would start answering 415 to a very common client + // shape. Those requests bind nothing today, but services that do not need + // the body work, and they would stop working. Left as it is deliberately. return nil } +// mediaType extracts the bare media type from a Content-Type header value. +// Per RFC 9110 the media type is case-insensitive and may be followed by +// parameters and arbitrary optional whitespace, so `Application/JSON` and +// `application/json ; charset=utf-8` must both resolve to `application/json`. +func mediaType(header string) string { + parsed, _, err := mime.ParseMediaType(header) + if err != nil && parsed == "" { + // The header is malformed beyond a bad parameter list; fall back to a + // best-effort parse rather than losing an otherwise usable media type. + return strings.ToLower(strings.TrimSpace(strings.Split(header, ";")[0])) + } + + return parsed +} + // HostName retrieves the hostname from the request. func (r *Request) HostName() string { proto := r.req.Header.Get("X-Forwarded-Proto") @@ -112,6 +152,14 @@ func (r *Request) Params(key string) []string { } func (r *Request) body() ([]byte, error) { + // A server-received request always has a non-nil Body, but one built by + // hand — as in a handler unit test — may not, and io.ReadAll(nil) panics. + // Treat an absent body as an empty one so callers get an ordinary decode + // error (or, for a type with no decoder, a no-op) instead of a crash. + if r.req.Body == nil { + return nil, nil + } + bodyBytes, err := io.ReadAll(r.req.Body) if err != nil { return nil, err diff --git a/pkg/gofr/http/request_test.go b/pkg/gofr/http/request_test.go index d246be5439..95ed9012eb 100644 --- a/pkg/gofr/http/request_test.go +++ b/pkg/gofr/http/request_test.go @@ -2,6 +2,7 @@ package http import ( "bytes" + "context" "errors" "io" "mime/multipart" @@ -12,6 +13,7 @@ import ( "strings" "testing" + "github.com/gorilla/mux" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -134,9 +136,15 @@ func TestBind_NoContentType(t *testing.T) { B int `json:"b"` }{} - _ = req.Bind(&x) + // A body with no Content-Type is a NO-OP: no error, and nothing bound. + // + // Rejecting it with 415 is tempting - the body is silently discarded - but + // fetch(url, {method:"POST", body: str}) with no headers sends text/plain, + // so a handler that returns the Bind error would start answering 415 to a + // very common client shape. Pinned as-is deliberately. + require.NoError(t, req.Bind(&x)) - // The data won't bind so zero values are expected + // The data does not bind, so zero values are expected. if x.A != "" || x.B != 0 { t.Errorf("Bind error. Got: %v", x) } @@ -316,14 +324,25 @@ func TestBind_BinaryOctetStream_NotPointerToByteSlice(t *testing.T) { } req.req.Header.Set("Content-Type", "binary/octet-stream") - err := req.Bind("invalid input") + // A non-pointer target is now rejected up front by Bind with errNonPointerBind + // rather than reaching bindBinary — binding into a value could never have + // worked, and it used to be silent for the JSON and binary paths (the + // form and multipart paths already returned errNonPointerBind). + if err := req.Bind("invalid input"); !errors.Is(err, errNonPointerBind) { + t.Fatalf("Expected error: %v, got: %v", errNonPointerBind, err) + } + + // A pointer to something that is not a []byte still reaches bindBinary. + var notBytes string + + err := req.Bind(¬Bytes) if !errors.Is(err, errNonSliceBind) { t.Fatalf("Expected error: %v, got: %v", errNonSliceBind, err) } - if !strings.Contains(err.Error(), "input is not a pointer to a byte slice: invalid input") { - t.Errorf("Expected error to contain: input is not a pointer to a byte slice: invalid input, got: %v", err) + if !strings.Contains(err.Error(), "input is not a pointer to a byte slice") { + t.Errorf("Expected error to contain: input is not a pointer to a byte slice, got: %v", err) } } @@ -390,7 +409,10 @@ func TestBind_UnsupportedContentType(t *testing.T) { err := r.Bind(&struct{}{}) - assert.NoError(t, err) + // A body the framework cannot decode is silently discarded: Bind is a no-op + // and reports no error. See TestBind_NoContentType for why this is not + // changed to a 415. + require.NoError(t, err) } func TestParam_NonExistent(t *testing.T) { @@ -409,3 +431,710 @@ func TestContext_ReturnsRequestContext(t *testing.T) { assert.Equal(t, httpReq.Context(), ctx) } + +// --------------------------------------------------------------------------- +// Characterization suite. +// +// Pins the CURRENT param/binding contract of Request: exact return values and +// exact error strings, including the sharp edges. Assertions are literal on +// purpose — a refactor that changes any of these changes handler behavior. +// --------------------------------------------------------------------------- + +// charBindTarget is the struct bound in the JSON characterization cases. +type charBindTarget struct { + A string `json:"a"` + B int `json:"b"` +} + +func newCharRequest(t *testing.T, target, contentType, body string) *Request { + t.Helper() + + r := httptest.NewRequestWithContext(t.Context(), http.MethodPost, target, strings.NewReader(body)) + if contentType != "" { + r.Header.Set("Content-Type", contentType) + } + + return NewRequest(r) +} + +// TestRequest_Char_Param pins Param: it reads ONLY the URL query string (never +// the body), returns the FIRST value for a repeated key, and returns "" for a +// key that is absent or whose value is empty. +func TestRequest_Char_Param(t *testing.T) { + tests := []struct { + name string + target string + key string + want string + }{ + {"single", "/x?a=b", "a", "b"}, + {"absent", "/x?a=b", "zzz", ""}, + {"no-query-string", "/x", "a", ""}, + {"empty-value", "/x?a=", "a", ""}, + {"bare-key", "/x?a", "a", ""}, + // A repeated key yields only the first value. + {"repeated-returns-first", "/x?a=1&a=2&a=3", "a", "1"}, + // Commas are NOT split by Param (unlike Params). + {"comma-not-split", "/x?a=1,2,3", "a", "1,2,3"}, + {"url-decoded", "/x?a=hello%20world%26", "a", "hello world&"}, + {"plus-is-space", "/x?a=hello+world", "a", "hello world"}, + // Keys are case sensitive. + {"case-sensitive-key", "/x?Abc=1", "abc", ""}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + req := NewRequest(httptest.NewRequestWithContext(t.Context(), http.MethodGet, tc.target, http.NoBody)) + + assert.Equal(t, tc.want, req.Param(tc.key)) + }) + } +} + +// TestRequest_Char_Params pins Params: every value for the key, each further +// split on commas. A missing key yields a nil slice (not an empty one). +func TestRequest_Char_Params(t *testing.T) { + tests := []struct { + name string + target string + key string + want []string + }{ + {"single", "/x?a=b", "a", []string{"b"}}, + {"repeated", "/x?a=1&a=2", "a", []string{"1", "2"}}, + {"comma-split", "/x?a=1,2,3", "a", []string{"1", "2", "3"}}, + {"repeated-and-comma-split", "/x?a=1,2&a=3", "a", []string{"1", "2", "3"}}, + // An empty value still produces one empty element, because + // strings.Split("", ",") returns []string{""}. + {"empty-value-yields-empty-element", "/x?a=", "a", []string{""}}, + {"bare-key-yields-empty-element", "/x?a", "a", []string{""}}, + // Trailing/leading commas produce empty elements — no trimming. + {"leading-trailing-commas", "/x?a=,1,", "a", []string{"", "1", ""}}, + {"absent-is-nil", "/x?a=b", "zzz", nil}, + {"no-query-string-is-nil", "/x", "a", nil}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + req := NewRequest(httptest.NewRequestWithContext(t.Context(), http.MethodGet, tc.target, http.NoBody)) + + got := req.Params(tc.key) + + assert.Equal(t, tc.want, got) + + if tc.want == nil { + assert.Nil(t, got, "a missing key must yield nil, not an empty slice") + } + }) + } +} + +// TestRequest_Char_PathParam pins PathParam against gorilla/mux vars: present +// keys return their value, everything else returns the empty string (never a +// panic), and lookups are case sensitive. +func TestRequest_Char_PathParam(t *testing.T) { + r := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/users/7", http.NoBody) + r = mux.SetURLVars(r, map[string]string{"id": "7", "Empty": "", "Mixed": "AbC"}) + + req := NewRequest(r) + + assert.Equal(t, "7", req.PathParam("id")) + assert.Empty(t, req.PathParam("Empty")) + assert.Equal(t, "AbC", req.PathParam("Mixed")) + assert.Empty(t, req.PathParam("mixed"), "path params are case sensitive") + assert.Empty(t, req.PathParam("missing")) + assert.Empty(t, req.PathParam("")) +} + +// TestRequest_Char_PathParamNoVars pins that a request never routed through mux +// has a nil pathParams map and every lookup safely returns "". +func TestRequest_Char_PathParamNoVars(t *testing.T) { + req := NewRequest(httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/x", http.NoBody)) + + assert.Nil(t, req.pathParams) + assert.Empty(t, req.PathParam("anything")) +} + +// TestRequest_Char_HostName pins HostName: "://", where proto is +// X-Forwarded-Proto when set and "http" otherwise. The header value is trusted +// and echoed verbatim — no allow-list, no validation. +func TestRequest_Char_HostName(t *testing.T) { + tests := []struct { + name string + host string + forwardedProt string + want string + }{ + {"default-proto", "example.com", "", "http://example.com"}, + {"forwarded-https", "example.com", "https", "https://example.com"}, + {"host-with-port", "example.com:8080", "", "http://example.com:8080"}, + // The proto header is echoed verbatim, whatever it says. + {"arbitrary-proto-echoed", "example.com", "gopher", "gopher://example.com"}, + {"forwarded-proto-list-echoed", "example.com", "https, http", "https, http://example.com"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + r := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/x", http.NoBody) + r.Host = tc.host + + if tc.forwardedProt != "" { + r.Header.Set("X-Forwarded-Proto", tc.forwardedProt) + } + + assert.Equal(t, tc.want, NewRequest(r).HostName()) + }) + } +} + +// TestRequest_Char_Context pins that Context returns the *same* context +// instance carried by the underlying http.Request. +func TestRequest_Char_Context(t *testing.T) { + type ctxKey struct{} + + base := context.WithValue(t.Context(), ctxKey{}, "v") + r := httptest.NewRequestWithContext(base, http.MethodGet, "/x", http.NoBody) + + got := NewRequest(r).Context() + + assert.Equal(t, base, got) + assert.Equal(t, "v", got.Value(ctxKey{})) +} + +// TestRequest_Char_BindJSON pins the JSON binding path, including the exact +// error strings produced by encoding/json for malformed input. +func TestRequest_Char_BindJSON(t *testing.T) { + tests := []struct { + name string + contentType string + body string + wantErr string + want charBindTarget + }{ + {"valid", "application/json", `{"a":"x","b":5}`, "", charBindTarget{A: "x", B: 5}}, + // Parameters are tolerated: the media type is parsed, not string-split. + {"with-charset", "application/json; charset=utf-8", `{"a":"x"}`, "", charBindTarget{A: "x"}}, + // FIXED (was a silent no-op): the header used to be split on ";" without + // trimming, so `application/json ; charset=utf-8` yielded "application/json " + // and matched nothing. mime.ParseMediaType now handles the whitespace. + {"with-space-before-semicolon", "application/json ; charset=utf-8", `{"a":"x"}`, "", charBindTarget{A: "x"}}, + // Unknown JSON keys are silently ignored. + {"unknown-keys-ignored", "application/json", `{"a":"x","zz":1}`, "", charBindTarget{A: "x"}}, + // Absent keys leave the target's existing (zero) value untouched. + {"partial-object", "application/json", `{"b":9}`, "", charBindTarget{B: 9}}, + {"json-null", "application/json", `null`, "", charBindTarget{}}, + {"empty-object", "application/json", `{}`, "", charBindTarget{}}, + + // --- malformed input ------------------------------------------------- + {"empty-body", "application/json", ``, "unexpected end of JSON input", charBindTarget{}}, + {"whitespace-only-body", "application/json", " ", "unexpected end of JSON input", charBindTarget{}}, + {"truncated", "application/json", `{"a":`, "unexpected end of JSON input", charBindTarget{}}, + { + "not-json", "application/json", `hello`, + "invalid character 'h' looking for beginning of value", charBindTarget{}, + }, + { + "unquoted-key", "application/json", `{a:1}`, + "invalid character 'a' looking for beginning of object key string", charBindTarget{}, + }, + { + "trailing-garbage", "application/json", `{"a":"x"} junk`, + "invalid character 'j' after top-level value", charBindTarget{}, + }, + + // --- type mismatches -------------------------------------------------- + // NOTE: on a type mismatch encoding/json still populates the fields it + // COULD decode, so the target is left partially bound alongside the error. + { + "wrong-type-for-string", "application/json", `{"a":5}`, + "json: cannot unmarshal number into Go struct field charBindTarget.a of type string", + charBindTarget{}, + }, + { + "wrong-type-for-int", "application/json", `{"a":"x","b":"nope"}`, + "json: cannot unmarshal string into Go struct field charBindTarget.b of type int", + charBindTarget{A: "x"}, + }, + { + "array-instead-of-object", "application/json", `[1,2]`, + "json: cannot unmarshal array into Go value of type http.charBindTarget", + charBindTarget{}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + req := newCharRequest(t, "/x", tc.contentType, tc.body) + + var got charBindTarget + + err := req.Bind(&got) + + if tc.wantErr == "" { + require.NoError(t, err) + } else { + require.Error(t, err) + assert.Equal(t, tc.wantErr, err.Error()) + } + + assert.Equal(t, tc.want, got) + }) + } +} + +// TestRequest_Char_BindNonPointerErrors pins the fix for a sharp edge: Bind +// takes `any` and unmarshals into `&i`, so a non-pointer target used to bind +// into a throwaway copy of the interface and a typo like `c.Bind(target)` failed +// completely silently. It now reports errNonPointerBind for every content type, +// matching what the form/multipart paths already did. +func TestRequest_Char_BindNonPointerErrors(t *testing.T) { + for _, ct := range []string{"application/json", "binary/octet-stream", "text/plain", ""} { + t.Run("ct="+ct, func(t *testing.T) { + req := newCharRequest(t, "/x", ct, `{"a":"x","b":5}`) + + target := charBindTarget{} + + err := req.Bind(target) + + require.ErrorIs(t, err, errNonPointerBind) + assert.Equal(t, "bind error, cannot bind to a non pointer type", err.Error()) + assert.Equal(t, charBindTarget{}, target, "the caller's value is still left untouched") + }) + } +} + +// TestRequest_Char_BindJSONIntoNonStructTargets pins binding into the +// non-struct pointer targets a handler may reasonably use. +func TestRequest_Char_BindJSONIntoNonStructTargets(t *testing.T) { + t.Run("map", func(t *testing.T) { + req := newCharRequest(t, "/x", "application/json", `{"a":1,"b":"s"}`) + + var got map[string]any + + require.NoError(t, req.Bind(&got)) + assert.Equal(t, map[string]any{"a": float64(1), "b": "s"}, got) + }) + + t.Run("slice", func(t *testing.T) { + req := newCharRequest(t, "/x", "application/json", `[1,2,3]`) + + var got []int + + require.NoError(t, req.Bind(&got)) + assert.Equal(t, []int{1, 2, 3}, got) + }) + + t.Run("string", func(t *testing.T) { + req := newCharRequest(t, "/x", "application/json", `"hello"`) + + var got string + + require.NoError(t, req.Bind(&got)) + assert.Equal(t, "hello", got) + }) + + t.Run("any", func(t *testing.T) { + req := newCharRequest(t, "/x", "application/json", `{"a":1}`) + + var got any + + require.NoError(t, req.Bind(&got)) + assert.Equal(t, map[string]any{"a": float64(1)}, got) + }) +} + +// TestRequest_Char_BindUnhandledContentTypes pins that a body Bind cannot decode +// is REPORTED rather than discarded. Returning nil here left the caller's target +// all-zero with no failure — the same silent no-op the non-pointer check rejects. +// A request with no body is the documented exception, covered separately below. +func TestRequest_Char_BindUnhandledContentTypes(t *testing.T) { + for _, ct := range []string{ + "", + "text/plain", + "application/xml", + "application/jsonx", // no prefix matching + "text/json", + } { + t.Run("ct="+ct, func(t *testing.T) { + req := newCharRequest(t, "/x", ct, `{"a":"x","b":5}`) + + var got charBindTarget + + err := req.Bind(&got) + + // A body that cannot be decoded is silently discarded: the target + // is left zeroed and no error is reported. + require.NoError(t, err) + assert.Equal(t, charBindTarget{}, got, "nothing is bound from an unhandled content type") + }) + } +} + +// TestRequest_Char_BindUnhandledContentTypeWithoutBody pins the carve-out: with +// no body there is nothing to decode and nothing lost, so binding an unhandled +// content type stays a no-op rather than erroring. This keeps handlers that Bind +// defensively on bodyless requests working. +func TestRequest_Char_BindUnhandledContentTypeWithoutBody(t *testing.T) { + for _, ct := range []string{"", "text/plain", "application/xml"} { + t.Run("ct="+ct, func(t *testing.T) { + req := newCharRequest(t, "/x", ct, "") + + var got charBindTarget + + require.NoError(t, req.Bind(&got)) + assert.Equal(t, charBindTarget{}, got) + }) + } +} + +// TestRequest_Char_BindContentTypeIsNormalized pins the fix for case-sensitive, +// untrimmed content-type matching. The case- and whitespace-variant spellings +// below used to fall through to the unhandled branch and silently leave the +// target all-zero (the exactly-canonical ones always worked); the media type is +// now parsed per RFC 9110, so case and surrounding whitespace are irrelevant. +func TestRequest_Char_BindContentTypeIsNormalized(t *testing.T) { + for _, ct := range []string{ + "application/json", + "application/JSON", + "Application/json", + "APPLICATION/JSON", + "application/json ", + " application/json", + "application/json;charset=utf-8", + "application/json ; charset=UTF-8", + } { + t.Run("ct="+ct, func(t *testing.T) { + req := newCharRequest(t, "/x", ct, `{"a":"x","b":5}`) + + var got charBindTarget + + require.NoError(t, req.Bind(&got)) + assert.Equal(t, charBindTarget{A: "x", B: 5}, got) + }) + } + + // The same normalization applies to the form path. + t.Run("form-urlencoded-trailing-space", func(t *testing.T) { + type target struct{ Name string } + + req := newCharRequest(t, "/x", "application/x-www-form-urlencoded ", "Name=alice") + + var got target + + require.NoError(t, req.Bind(&got)) + assert.Equal(t, target{Name: "alice"}, got) + }) +} + +// TestRequest_Char_BindFormURLEncoded pins the form-urlencoded path, including +// the exact sentinel errors. +func TestRequest_Char_BindFormURLEncoded(t *testing.T) { + type target struct { + Name string + Age int + OK bool + } + + t.Run("valid", func(t *testing.T) { + req := newCharRequest(t, "/x", + "application/x-www-form-urlencoded", "Name=alice&Age=30&OK=true") + + var got target + + require.NoError(t, req.Bind(&got)) + assert.Equal(t, target{Name: "alice", Age: 30, OK: true}, got) + }) + + // Top-level form keys are matched against the exact Go field name (or the + // `form`/`file` tag) — the match is CASE SENSITIVE, unlike the nested + // struct-string parser in setStructValue. + t.Run("field-names-are-case-sensitive", func(t *testing.T) { + req := newCharRequest(t, "/x", + "application/x-www-form-urlencoded", "name=alice&AGE=30") + + var got target + + require.ErrorIs(t, req.Bind(&got), errFieldsNotSet) + assert.Equal(t, target{}, got) + }) + + t.Run("form-tag-overrides-field-name", func(t *testing.T) { + type tagged struct { + Name string `form:"user_name"` + } + + req := newCharRequest(t, "/x", + "application/x-www-form-urlencoded", "user_name=alice&Name=bob") + + var got tagged + + require.NoError(t, req.Bind(&got)) + assert.Equal(t, tagged{Name: "alice"}, got) + }) + + t.Run("no-matching-field-returns-errFieldsNotSet", func(t *testing.T) { + req := newCharRequest(t, "/x", + "application/x-www-form-urlencoded", "unknown=1") + + var got target + + err := req.Bind(&got) + + require.ErrorIs(t, err, errFieldsNotSet) + assert.Equal(t, target{}, got) + }) + + t.Run("empty-body-returns-errFieldsNotSet", func(t *testing.T) { + req := newCharRequest(t, "/x", "application/x-www-form-urlencoded", "") + + var got target + + require.ErrorIs(t, req.Bind(&got), errFieldsNotSet) + }) +} + +// TestRequest_Char_BindFormURLEncodedErrors pins the form-urlencoded failure +// modes and the query-string leak. +func TestRequest_Char_BindFormURLEncodedErrors(t *testing.T) { + type target struct { + Name string + Age int + OK bool + } + + t.Run("non-pointer-target-returns-errNonPointerBind", func(t *testing.T) { + req := newCharRequest(t, "/x", + "application/x-www-form-urlencoded", "Name=alice") + + err := req.Bind(target{}) + + require.ErrorIs(t, err, errNonPointerBind) + assert.Equal(t, "bind error, cannot bind to a non pointer type", err.Error()) + }) + + t.Run("malformed-escape-returns-parse-error", func(t *testing.T) { + req := newCharRequest(t, "/x", + "application/x-www-form-urlencoded", "Name=%zz") + + var got target + + err := req.Bind(&got) + + require.Error(t, err) + assert.Equal(t, `invalid URL escape "%zz"`, err.Error()) + }) + + // SHARP EDGE (pinned as-is): ParseForm merges the URL query into r.Form, so + // a query parameter can populate a body-bound field. A client can therefore + // set form fields via the URL even when the body does not mention them. + t.Run("query-string-leaks-into-form-binding", func(t *testing.T) { + req := newCharRequest(t, "/x?Age=99", + "application/x-www-form-urlencoded", "Name=alice") + + var got target + + require.NoError(t, req.Bind(&got)) + assert.Equal(t, target{Name: "alice", Age: 99}, got) + }) + + // The raw strconv error is surfaced verbatim to the handler: it names no + // field, so the caller cannot tell WHICH input was bad. + t.Run("type-mismatch-surfaces-raw-strconv-error", func(t *testing.T) { + req := newCharRequest(t, "/x", + "application/x-www-form-urlencoded", "Name=alice&Age=notanumber") + + var got target + + err := req.Bind(&got) + + require.Error(t, err) + assert.Equal(t, `strconv.ParseInt: parsing "notanumber": invalid syntax`, err.Error()) + }) +} + +// TestRequest_Char_BindMultipartErrors pins the multipart failure modes. +func TestRequest_Char_BindMultipartErrors(t *testing.T) { + type target struct { + Name string + } + + t.Run("non-pointer-target-returns-errNonPointerBind", func(t *testing.T) { + req := newCharRequest(t, "/x", "multipart/form-data; boundary=xx", "") + + require.ErrorIs(t, req.Bind(target{}), errNonPointerBind) + }) + + t.Run("missing-boundary-returns-parse-error", func(t *testing.T) { + req := newCharRequest(t, "/x", "multipart/form-data", "") + + var got target + + err := req.Bind(&got) + + require.Error(t, err) + assert.Equal(t, "no multipart boundary param in Content-Type", err.Error()) + }) + + t.Run("no-matching-field-returns-errNoFileFound", func(t *testing.T) { + body := "--xx\r\nContent-Disposition: form-data; name=\"other\"\r\n\r\nv\r\n--xx--\r\n" + req := newCharRequest(t, "/x", "multipart/form-data; boundary=xx", body) + + var got target + + err := req.Bind(&got) + + require.ErrorIs(t, err, errNoFileFound) + assert.Equal(t, "no files were bounded", err.Error()) + }) + + t.Run("valid-text-field-binds", func(t *testing.T) { + body := "--xx\r\nContent-Disposition: form-data; name=\"Name\"\r\n\r\nalice\r\n--xx--\r\n" + req := newCharRequest(t, "/x", "multipart/form-data; boundary=xx", body) + + var got target + + require.NoError(t, req.Bind(&got)) + assert.Equal(t, target{Name: "alice"}, got) + }) +} + +// TestRequest_Char_BindBinary pins the binary/octet-stream path and its exact +// error for a target that is not a *[]byte. +func TestRequest_Char_BindBinary(t *testing.T) { + t.Run("binds-raw-bytes", func(t *testing.T) { + req := newCharRequest(t, "/x", "binary/octet-stream", "\x00\x01raw") + + var got []byte + + require.NoError(t, req.Bind(&got)) + assert.Equal(t, []byte("\x00\x01raw"), got) + }) + + t.Run("empty-body-binds-empty-slice", func(t *testing.T) { + req := newCharRequest(t, "/x", "binary/octet-stream", "") + + var got []byte + + require.NoError(t, req.Bind(&got)) + assert.Empty(t, got) + }) + + t.Run("wrong-target-type-error-message", func(t *testing.T) { + req := newCharRequest(t, "/x", "binary/octet-stream", "raw") + + var got string + + err := req.Bind(&got) + + require.ErrorIs(t, err, errNonSliceBind) + // The message interpolates the target with %v, which for a pointer is a + // non-deterministic address — so only the stable prefix is pinned. + assert.True(t, strings.HasPrefix(err.Error(), + "bind error: input is not a pointer to a byte slice: 0x"), err.Error()) + }) +} + +// TestRequest_Char_BodyIsReplayable pins that body() restores r.Body, so Bind +// can be called more than once and downstream readers still see the payload. +func TestRequest_Char_BodyIsReplayable(t *testing.T) { + req := newCharRequest(t, "/x", "application/json", `{"a":"x","b":5}`) + + var first, second charBindTarget + + require.NoError(t, req.Bind(&first)) + require.NoError(t, req.Bind(&second)) + + assert.Equal(t, charBindTarget{A: "x", B: 5}, first) + assert.Equal(t, charBindTarget{A: "x", B: 5}, second) + + // The raw body is still readable afterwards. + rest, err := io.ReadAll(req.req.Body) + require.NoError(t, err) + //nolint:testifylint // exact bytes are the contract. + assert.Equal(t, `{"a":"x","b":5}`, string(rest)) +} + +// TestRequest_Char_BindNilBodyDoesNotPanic pins that a request built without a +// body — the standard handler unit-test construction, where http.NewRequest +// leaves Body nil — never panics. +// +// io.ReadAll(nil) panics, so every path that reads the body has to tolerate an +// absent one. A decodable content type reports an ordinary decode error and one +// with no decoder stays a no-op, matching the documented "no body, nothing +// lost" rule. +func TestRequest_Char_BindNilBodyDoesNotPanic(t *testing.T) { + for _, tc := range []struct { + contentType string + wantErr bool + }{ + {"application/json", true}, // empty input is a JSON decode error + {"text/plain", false}, // no decoder, no body: nothing to do + {"", false}, + } { + t.Run("ct="+tc.contentType, func(t *testing.T) { + // nil, not http.NoBody: a nil Body is exactly what this pins. + //nolint:gocritic // httpNoBody — the nil body IS the case under test. + req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, "http://dummy/x", nil) + require.NoError(t, err) + require.Nil(t, req.Body, "precondition: the request carries no body") + + if tc.contentType != "" { + req.Header.Set("Content-Type", tc.contentType) + } + + var got charBindTarget + + require.NotPanics(t, func() { + bindErr := NewRequest(req).Bind(&got) + if tc.wantErr { + require.Error(t, bindErr) + } else { + require.NoError(t, bindErr) + } + }) + + assert.Equal(t, charBindTarget{}, got) + }) + } +} + +// TestBind_OctetStreamSpellings pins that both spellings decode. GoFr's own +// "binary/octet-stream" is non-standard; "application/octet-stream" is the RFC +// 2046 one and the likelier thing a client sends. Before the reject path +// existed the RFC spelling was a silent no-op, so rejecting it would have +// turned a naming gap into a hard error for the commoner spelling. +func TestBind_OctetStreamSpellings(t *testing.T) { + for _, ct := range []string{"binary/octet-stream", "application/octet-stream"} { + t.Run(ct, func(t *testing.T) { + req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/", strings.NewReader("payload")) + req.Header.Set("Content-Type", ct) + + var target []byte + + require.NoError(t, NewRequest(req).Bind(&target)) + assert.Equal(t, []byte("payload"), target) + }) + } +} + +// TestBind_EmptyBodyWithUnsupportedTypeIsNoOp pins the other half: with nothing +// to decode there is nothing lost, so Bind stays a no-op for callers that bind +// defensively on bodyless requests. +func TestBind_EmptyBodyWithUnsupportedTypeIsNoOp(t *testing.T) { + for _, tc := range []struct { + name string + body io.Reader + }{ + {"no body", http.NoBody}, + {"empty reader", strings.NewReader("")}, + } { + t.Run(tc.name, func(t *testing.T) { + req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/", tc.body) + req.Header.Set("Content-Type", "text/plain") + + var target struct{} + + assert.NoError(t, NewRequest(req).Bind(&target)) + }) + } +} diff --git a/pkg/gofr/http/responder_test.go b/pkg/gofr/http/responder_test.go index 7f8fc5be72..34e9079ade 100644 --- a/pkg/gofr/http/responder_test.go +++ b/pkg/gofr/http/responder_test.go @@ -896,3 +896,937 @@ func BenchmarkResponderRespond(b *testing.B) { r.Respond(data, nil) } } + +// --------------------------------------------------------------------------- +// Characterization suite. +// +// Everything below pins the CURRENT observable wire contract of Responder: +// exact status code, exact Content-Type, exact body bytes. It is deliberately +// exhaustive and deliberately literal — no Contains, no "not empty". A refactor +// that changes any byte a client sees must fail here. +// --------------------------------------------------------------------------- + +var ( + errCharPlain = errors.New("plain failure") +) + +// charError is an error carrying an arbitrary status code, used to pin the +// StatusCodeResponder branch without depending on a concrete GoFr error type. +type charError struct { + msg string + code int +} + +func (e charError) Error() string { return e.msg } +func (e charError) StatusCode() int { return e.code } + +// charMarshallerError implements both StatusCodeResponder and ResponseMarshaller +// so the merge behavior of createErrorResponse is pinned by value type (the +// existing CustomError is a pointer type). +type charMarshallerError struct{} + +func (charMarshallerError) Error() string { return "validation failed" } +func (charMarshallerError) StatusCode() int { return http.StatusBadRequest } +func (charMarshallerError) Response() map[string]any { + return map[string]any{"field": "email", "reason": "bad format"} +} + +type charStruct struct { + ID int `json:"id"` + Name string `json:"name"` +} + +type charOmit struct { + ID int `json:"id,omitempty"` + Name string `json:"name,omitempty"` +} + +// respondCase is one exact-bytes expectation for Respond. +type respondCase struct { + name string + method string + data any + err error + wantStatus int + wantCType string + wantBody string +} + +func runRespondCases(t *testing.T, cases []respondCase) { + t.Helper() + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + w := httptest.NewRecorder() + + NewResponder(w, tc.method).Respond(tc.data, tc.err) + + assert.Equal(t, tc.wantStatus, w.Code, "status code") + assert.Equal(t, tc.wantCType, w.Header().Get("Content-Type"), "Content-Type") + assert.Equal(t, tc.wantBody, w.Body.String(), "body bytes") + }) + } +} + +// TestResponder_Char_SuccessEnvelope pins the success-path envelope for every +// shape of data GoFr allows a handler to return, across every HTTP method whose +// status mapping differs. +func TestResponder_Char_SuccessEnvelope(t *testing.T) { + runRespondCases(t, []respondCase{ + // --- nil data, per method ------------------------------------------- + {"nil-get", http.MethodGet, nil, nil, http.StatusOK, "application/json", "{}\n"}, + {"nil-post", http.MethodPost, nil, nil, http.StatusAccepted, "application/json", "{}\n"}, + {"nil-put", http.MethodPut, nil, nil, http.StatusOK, "application/json", "{}\n"}, + {"nil-patch", http.MethodPatch, nil, nil, http.StatusOK, "application/json", "{}\n"}, + {"nil-delete", http.MethodDelete, nil, nil, http.StatusNoContent, "application/json", "{}\n"}, + {"nil-head", http.MethodHead, nil, nil, http.StatusOK, "application/json", "{}\n"}, + {"nil-options", http.MethodOptions, nil, nil, http.StatusOK, "application/json", "{}\n"}, + {"nil-empty-method", "", nil, nil, http.StatusOK, "application/json", "{}\n"}, + + // --- primitives ------------------------------------------------------ + {"string", http.MethodGet, "hello", nil, http.StatusOK, "application/json", "{\"data\":\"hello\"}\n"}, + // NOTE: `data` carries `omitempty`, but its Go type is `any`, so only a + // nil INTERFACE is omitted. A zero-valued primitive is still emitted. + {"empty-string", http.MethodGet, "", nil, http.StatusOK, "application/json", "{\"data\":\"\"}\n"}, + {"int", http.MethodGet, 42, nil, http.StatusOK, "application/json", "{\"data\":42}\n"}, + {"zero-int", http.MethodGet, 0, nil, http.StatusOK, "application/json", "{\"data\":0}\n"}, + {"negative-int", http.MethodGet, -7, nil, http.StatusOK, "application/json", "{\"data\":-7}\n"}, + {"float", http.MethodGet, 3.5, nil, http.StatusOK, "application/json", "{\"data\":3.5}\n"}, + {"bool-true", http.MethodGet, true, nil, http.StatusOK, "application/json", "{\"data\":true}\n"}, + {"bool-false", http.MethodGet, false, nil, http.StatusOK, "application/json", "{\"data\":false}\n"}, + + // --- structs ---------------------------------------------------------- + { + "struct", http.MethodGet, charStruct{ID: 1, Name: "a"}, nil, + http.StatusOK, "application/json", "{\"data\":{\"id\":1,\"name\":\"a\"}}\n", + }, + { + "zero-struct-no-omitempty", http.MethodGet, charStruct{}, nil, + http.StatusOK, "application/json", "{\"data\":{\"id\":0,\"name\":\"\"}}\n", + }, + { + "zero-struct-all-omitempty", http.MethodGet, charOmit{}, nil, + http.StatusOK, "application/json", "{\"data\":{}}\n", + }, + { + "pointer-to-struct", http.MethodGet, &charStruct{ID: 2, Name: "b"}, nil, + http.StatusOK, "application/json", "{\"data\":{\"id\":2,\"name\":\"b\"}}\n", + }, + // A typed nil pointer inside the interface: isNil() collapses it to nil + // for the BODY, but handleSuccess sees a non-nil interface, so a POST + // still reports 201 Created rather than 202 Accepted. + {"typed-nil-ptr-get", http.MethodGet, newNilTemp(), nil, http.StatusOK, "application/json", "{}\n"}, + {"typed-nil-ptr-post", http.MethodPost, newNilTemp(), nil, http.StatusCreated, "application/json", "{}\n"}, + + // --- slices / arrays / maps ------------------------------------------- + {"slice-of-int", http.MethodGet, []int{1, 2, 3}, nil, http.StatusOK, "application/json", "{\"data\":[1,2,3]}\n"}, + {"empty-slice", http.MethodGet, []int{}, nil, http.StatusOK, "application/json", "{\"data\":[]}\n"}, + // A nil SLICE is a non-nil interface, so it survives omitempty and the + // client sees an explicit `null` — unlike a nil POINTER, which isNil() + // collapses to an absent `data` key. Two ways to say "nothing". + {"nil-slice", http.MethodGet, []int(nil), nil, http.StatusOK, "application/json", "{\"data\":null}\n"}, + { + "slice-of-struct", http.MethodGet, []charStruct{{ID: 1, Name: "a"}, {ID: 2, Name: "b"}}, nil, + http.StatusOK, "application/json", "{\"data\":[{\"id\":1,\"name\":\"a\"},{\"id\":2,\"name\":\"b\"}]}\n", + }, + {"array", http.MethodGet, [2]int{9, 8}, nil, http.StatusOK, "application/json", "{\"data\":[9,8]}\n"}, + { + "map-string-string", http.MethodGet, map[string]string{"k": "v"}, nil, + http.StatusOK, "application/json", "{\"data\":{\"k\":\"v\"}}\n", + }, + {"empty-map", http.MethodGet, map[string]string{}, nil, http.StatusOK, "application/json", "{\"data\":{}}\n"}, + {"nil-map", http.MethodGet, map[string]string(nil), nil, http.StatusOK, "application/json", "{\"data\":null}\n"}, + // Map keys are emitted in sorted order by encoding/json, so this is + // deterministic despite Go's randomized map iteration. + { + "map-key-ordering", http.MethodGet, map[string]int{"z": 1, "a": 2, "m": 3}, nil, + http.StatusOK, "application/json", "{\"data\":{\"a\":2,\"m\":3,\"z\":1}}\n", + }, + }) +} + +// TestResponder_Char_RawEnvelope pins resTypes.Raw: the envelope is bypassed +// entirely and Data is written as the whole body. +func TestResponder_Char_RawEnvelope(t *testing.T) { + runRespondCases(t, []respondCase{ + { + "raw-map", http.MethodGet, resTypes.Raw{Data: map[string]string{"k": "v"}}, nil, + http.StatusOK, "application/json", "{\"k\":\"v\"}\n", + }, + {"raw-string", http.MethodGet, resTypes.Raw{Data: "plain"}, nil, http.StatusOK, "application/json", "\"plain\"\n"}, + { + "raw-slice", http.MethodGet, resTypes.Raw{Data: []int{1, 2}}, nil, + http.StatusOK, "application/json", "[1,2]\n", + }, + // Raw{} is a zero struct, so a POST sees non-nil data and reports 201. + {"raw-nil-data-get", http.MethodGet, resTypes.Raw{}, nil, http.StatusOK, "application/json", "null\n"}, + {"raw-nil-data-post", http.MethodPost, resTypes.Raw{Data: "x"}, nil, http.StatusCreated, "application/json", "\"x\"\n"}, + + // LATENT BUG (pinned as-is): when a handler returns a Raw alongside an + // error, the status becomes 206 Partial Content but the error object is + // silently DROPPED from the body — the client gets only Raw.Data and no + // indication of what failed. + { + "raw-with-error-drops-error", http.MethodGet, resTypes.Raw{Data: "partial"}, errCharPlain, + http.StatusPartialContent, "application/json", "\"partial\"\n", + }, + // LATENT BUG (pinned as-is): Raw{} is an empty struct, so isEmptyStruct + // fires and the status becomes 500 with errEmptyResponse — but the body + // is still the raw `null`, not the error envelope. + { + "raw-empty-with-error", http.MethodGet, resTypes.Raw{}, errCharPlain, + http.StatusInternalServerError, "application/json", "null\n", + }, + }) +} + +// TestResponder_Char_ResponseEnvelope pins resTypes.Response, including the +// Metadata field and the fixed JSON field ordering (error, metadata, data). +func TestResponder_Char_ResponseEnvelope(t *testing.T) { + runRespondCases(t, []respondCase{ + { + "response-data-only", http.MethodGet, + resTypes.Response{Data: map[string]string{"k": "v"}}, nil, + http.StatusOK, "application/json", "{\"data\":{\"k\":\"v\"}}\n", + }, + { + "response-with-metadata", http.MethodGet, + resTypes.Response{Data: "d", Metadata: map[string]any{"page": 1}}, nil, + http.StatusOK, "application/json", "{\"metadata\":{\"page\":1},\"data\":\"d\"}\n", + }, + // Field order in the envelope is error, then metadata, then data. + { + "response-metadata-and-error-ordering", http.MethodGet, + resTypes.Response{Data: "d", Metadata: map[string]any{"page": 1}}, errCharPlain, + http.StatusPartialContent, "application/json", + "{\"error\":{\"message\":\"plain failure\"},\"metadata\":{\"page\":1},\"data\":\"d\"}\n", + }, + { + "response-empty-metadata-omitted", http.MethodGet, + resTypes.Response{Data: "d", Metadata: map[string]any{}}, nil, + http.StatusOK, "application/json", "{\"data\":\"d\"}\n", + }, + // Headers are declared `json:"-"` and are applied by the caller + // (handler.ServeHTTP), never by Respond. + { + "response-headers-not-serialized", http.MethodGet, + resTypes.Response{Data: "d", Headers: map[string]string{"X-A": "b"}}, nil, + http.StatusOK, "application/json", "{\"data\":\"d\"}\n", + }, + // LATENT QUIRK (pinned as-is): resTypes.Response{} is a zero struct, so + // isEmptyStruct fires: the status becomes 500 AND the caller's real + // error is replaced by the generic "internal server error". + { + "response-empty-with-error", http.MethodGet, + resTypes.Response{}, errCharPlain, + http.StatusInternalServerError, "application/json", "{\"error\":{\"message\":\"internal server error\"}}\n", + }, + }) +} + +// TestResponder_Char_ResponseHeadersNotApplied pins that Respond does NOT apply +// resTypes.Response.Headers to the writer — that is handler.ServeHTTP's job. +func TestResponder_Char_ResponseHeadersNotApplied(t *testing.T) { + w := httptest.NewRecorder() + + NewResponder(w, http.MethodGet).Respond(resTypes.Response{ + Data: "d", + Headers: map[string]string{"X-Custom": "v"}, + }, nil) + + assert.Empty(t, w.Header().Get("X-Custom"), "Respond must not set Response.Headers") +} + +// TestResponder_Char_ErrorEnvelope pins the exact `{"error":{...}}` envelope and +// status code for every error type GoFr maps, plus the generic branches. +func TestResponder_Char_ErrorEnvelope(t *testing.T) { + runRespondCases(t, []respondCase{ + // --- plain error (no StatusCodeResponder) -> 500 ---------------------- + { + "plain-error", http.MethodGet, nil, errCharPlain, + http.StatusInternalServerError, "application/json", "{\"error\":{\"message\":\"plain failure\"}}\n", + }, + // The method-based success mapping is NOT consulted on the error path: + // a failed DELETE is 500, not 204. + { + "plain-error-delete", http.MethodDelete, nil, errCharPlain, + http.StatusInternalServerError, "application/json", "{\"error\":{\"message\":\"plain failure\"}}\n", + }, + { + "plain-error-post", http.MethodPost, nil, errCharPlain, + http.StatusInternalServerError, "application/json", "{\"error\":{\"message\":\"plain failure\"}}\n", + }, + + // --- GoFr status-coded errors ---------------------------------------- + { + "entity-not-found", http.MethodGet, nil, ErrorEntityNotFound{Name: "id", Value: "2"}, + http.StatusNotFound, "application/json", "{\"error\":{\"message\":\"No entity found with id: 2\"}}\n", + }, + { + "entity-not-found-zero", http.MethodGet, nil, ErrorEntityNotFound{}, + http.StatusNotFound, "application/json", "{\"error\":{\"message\":\"No entity found with : \"}}\n", + }, + { + "entity-already-exists", http.MethodGet, nil, ErrorEntityAlreadyExist{}, + http.StatusConflict, "application/json", "{\"error\":{\"message\":\"entity already exists\"}}\n", + }, + // NOTE: ErrorInvalidParam has a `json:"param,omitempty"` tag, but it does + // NOT implement ResponseMarshaller, so the param list never reaches the + // wire — the client only ever sees the rendered message string. + { + "invalid-param", http.MethodGet, nil, ErrorInvalidParam{Params: []string{"a", "b"}}, + http.StatusBadRequest, "application/json", "{\"error\":{\"message\":\"'2' invalid parameter(s): a, b\"}}\n", + }, + { + "invalid-param-empty", http.MethodGet, nil, ErrorInvalidParam{}, + http.StatusBadRequest, "application/json", "{\"error\":{\"message\":\"'0' invalid parameter(s): \"}}\n", + }, + { + "missing-param", http.MethodGet, nil, ErrorMissingParam{Params: []string{"id"}}, + http.StatusBadRequest, "application/json", "{\"error\":{\"message\":\"'1' missing parameter(s): id\"}}\n", + }, + { + "invalid-route", http.MethodGet, nil, ErrorInvalidRoute{}, + http.StatusNotFound, "application/json", "{\"error\":{\"message\":\"route not registered\"}}\n", + }, + { + "request-timeout", http.MethodGet, nil, ErrorRequestTimeout{}, + http.StatusRequestTimeout, "application/json", "{\"error\":{\"message\":\"request timed out\"}}\n", + }, + { + "client-closed-request", http.MethodGet, nil, ErrorClientClosedRequest{}, + StatusClientClosedRequest, "application/json", "{\"error\":{\"message\":\"client closed request\"}}\n", + }, + { + "panic-recovery", http.MethodGet, nil, ErrorPanicRecovery{}, + http.StatusInternalServerError, "application/json", "{\"error\":{\"message\":\"Internal Server Error\"}}\n", + }, + { + "too-many-requests", http.MethodGet, nil, ErrorTooManyRequests{}, + http.StatusTooManyRequests, "application/json", "{\"error\":{\"message\":\"rate limit exceeded\"}}\n", + }, + { + "service-unavailable-bare", http.MethodGet, nil, ErrorServiceUnavailable{}, + http.StatusServiceUnavailable, "application/json", "{\"error\":{\"message\":\"Service Unavailable\"}}\n", + }, + { + "service-unavailable-detailed", http.MethodGet, nil, + ErrorServiceUnavailable{Dependency: "redis", ErrorMessage: "dial fail"}, + http.StatusServiceUnavailable, "application/json", + "{\"error\":{\"message\":\"Service unavailable due to error: dial fail from dependency redis\"}}\n", + }, + }) +} + +// TestResponder_Char_ErrorEnvelopeEdges pins the remaining error-path branches: +// arbitrary status codes, the ResponseMarshaller merge, the 206 partial-content +// rule and the empty-struct short circuit. +func TestResponder_Char_ErrorEnvelopeEdges(t *testing.T) { + runRespondCases(t, []respondCase{ + // --- arbitrary status-coded error ------------------------------------ + { + "custom-status-code", http.MethodGet, nil, charError{msg: "teapot", code: http.StatusTeapot}, + http.StatusTeapot, "application/json", "{\"error\":{\"message\":\"teapot\"}}\n", + }, + // A StatusCodeResponder reporting 0 is normalized to 500 by + // determineResponse. + { + "zero-status-code-normalized-to-500", http.MethodGet, nil, charError{msg: "zero", code: 0}, + http.StatusInternalServerError, "application/json", "{\"error\":{\"message\":\"zero\"}}\n", + }, + + // --- ResponseMarshaller merge ---------------------------------------- + // Extra fields are merged in alongside "message"; keys are emitted in + // sorted order because the error object is a map. + { + "response-marshaller-merge", http.MethodGet, nil, charMarshallerError{}, + http.StatusBadRequest, "application/json", + "{\"error\":{\"field\":\"email\",\"message\":\"validation failed\",\"reason\":\"bad format\"}}\n", + }, + + // --- data + error -> 206 Partial Content ------------------------------ + // NOTE: the error's own StatusCode() is ignored entirely when data is + // non-nil; a 404 alongside partial data is reported as 206. + { + "data-plus-statuscoded-error-is-206", http.MethodGet, map[string]string{"k": "v"}, + ErrorEntityNotFound{Name: "id", Value: "9"}, + http.StatusPartialContent, "application/json", + "{\"error\":{\"message\":\"No entity found with id: 9\"},\"data\":{\"k\":\"v\"}}\n", + }, + // A typed nil pointer counts as nil here, so the error's status wins. + { + "typed-nil-data-plus-error-uses-error-status", http.MethodGet, newNilTemp(), ErrorEntityNotFound{}, + http.StatusNotFound, "application/json", "{\"error\":{\"message\":\"No entity found with : \"}}\n", + }, + + // --- empty-struct short circuit --------------------------------------- + // LATENT QUIRK (pinned as-is): when data is a zero-valued struct AND an + // error is present, determineResponse replaces the status with 500 and + // the error object with the generic "internal server error" — the real + // error is lost. The zero struct is still serialized into `data`. + { + "empty-struct-plus-error", http.MethodGet, charStruct{}, ErrorEntityNotFound{Name: "id", Value: "1"}, + http.StatusInternalServerError, "application/json", + "{\"error\":{\"message\":\"internal server error\"},\"data\":{\"id\":0,\"name\":\"\"}}\n", + }, + // ...but a POINTER to a zero struct escapes the short circuit, because + // isEmptyStruct dereferences for the Kind check yet compares the + // original pointer against a zero STRUCT. Different behavior for + // semantically identical data. + { + "pointer-to-empty-struct-plus-error", http.MethodGet, &charStruct{}, ErrorEntityNotFound{Name: "id", Value: "1"}, + http.StatusPartialContent, "application/json", + "{\"error\":{\"message\":\"No entity found with id: 1\"},\"data\":{\"id\":0,\"name\":\"\"}}\n", + }, + // A zero struct whose fields are all omitempty still serializes as `{}` + // under `data`, so the client cannot distinguish it from "no data". + { + "empty-omitempty-struct-plus-error", http.MethodGet, charOmit{}, errCharPlain, + http.StatusInternalServerError, "application/json", + "{\"error\":{\"message\":\"internal server error\"},\"data\":{}}\n", + }, + }) +} + +// TestResponder_Char_JSONEscaping pins encoding/json's default HTML escaping. +// GoFr uses json.Encoder without SetEscapeHTML(false), so <, > and & become +// \u003c, \u003e and \u0026 on the wire, and U+2028/U+2029 are escaped too. +func TestResponder_Char_JSONEscaping(t *testing.T) { + runRespondCases(t, []respondCase{ + { + "html-angle-brackets", http.MethodGet, "", nil, + http.StatusOK, "application/json", + "{\"data\":\"\\u003cscript\\u003ealert(1)\\u003c/script\\u003e\"}\n", + }, + { + "ampersand", http.MethodGet, "a & b", nil, + http.StatusOK, "application/json", "{\"data\":\"a \\u0026 b\"}\n", + }, + { + "double-quote-and-backslash", http.MethodGet, `he said "hi" \ bye`, nil, + http.StatusOK, "application/json", "{\"data\":\"he said \\\"hi\\\" \\\\ bye\"}\n", + }, + { + "control-chars", http.MethodGet, "line1\nline2\ttab", nil, + http.StatusOK, "application/json", "{\"data\":\"line1\\nline2\\ttab\"}\n", + }, + // Non-ASCII is emitted as raw UTF-8, not \u escapes. + { + "unicode-passthrough", http.MethodGet, "héllo 世界 🚀", nil, + http.StatusOK, "application/json", "{\"data\":\"héllo 世界 🚀\"}\n", + }, + // U+2028 LINE SEPARATOR / U+2029 PARAGRAPH SEPARATOR are escaped so the + // body is safe to embed in a