From 1f454ef466cacf3ff72d9355c8589bbb89772a51 Mon Sep 17 00:00:00 2001 From: Dan Kortschak Date: Wed, 17 Jun 2026 15:53:08 +0930 Subject: [PATCH] internal/httpserver: add now template function for dynamic timestamps Add a "now" template function to the HTTP mock server that returns the current UTC time. An optional Go duration string offsets the result, e.g. {{ (now "-720h").Format "2006-01-02T15:04:05Z07:00" }} for 30 days ago. This allows integration test mock configs to produce timestamps relative to the current time instead of hardcoding dates that eventually age past time-windowed queries. --- .changelog/205.txt | 3 + README.md | 1 + internal/httpserver/config.go | 19 ++++ internal/httpserver/httpserver_test.go | 125 +++++++++++++++++++++++++ 4 files changed, 148 insertions(+) create mode 100644 .changelog/205.txt diff --git a/.changelog/205.txt b/.changelog/205.txt new file mode 100644 index 0000000..4baad79 --- /dev/null +++ b/.changelog/205.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +http-server: Add `now` template function for dynamic timestamps with optional duration offset. +``` diff --git a/README.md b/README.md index dca5ea4..2d47428 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,7 @@ When using [Go templates](https://golang.org/pkg/text/template/) as part of the - `sum A B`: function that returns the sum of numbers A and B (only for integers). - `file PATH`: function that returns the contents of the file at PATH. - `glob PATTERN`: function that returns the names of all files matching glob PATTERN (see [filepath.Match](https://pkg.go.dev/path/filepath#Match) for syntax). +- `now [OFFSET]`: function that returns the current UTC time as a Go `time.Time` value. An optional Go duration string offsets the result (e.g. `{{ now "-720h" }}` for 30 days ago). The returned value exposes all `time.Time` methods, so it can be formatted in templates: `{{ (now).Format "2006-01-02T15:04:05Z07:00" }}`. - `.req_num`: variable containing the current request number, auto incremented after every request for the rule. - `.request.vars`: map containing the variables received in the request (both query and form). - `.request.url`: the url object. Can be used as per [the Go URL documentation.](https://golang.org/pkg/net/url/#URL) diff --git a/internal/httpserver/config.go b/internal/httpserver/config.go index 3210a1e..4628282 100644 --- a/internal/httpserver/config.go +++ b/internal/httpserver/config.go @@ -7,10 +7,12 @@ package httpserver import ( "encoding/json" "errors" + "fmt" "os" "path/filepath" "strings" "text/template" + "time" ucfg "github.com/elastic/go-ucfg" "github.com/elastic/go-ucfg/yaml" @@ -54,6 +56,7 @@ func (t *tpl) Unpack(in string) error { "file": file, "glob": filepath.Glob, "minify_json": minify, + "now": now, }). Parse(in) if err != nil { @@ -111,3 +114,19 @@ func minify(body string) (string, error) { err := enc.Encode(json.RawMessage(body)) return strings.TrimSpace(buf.String()), err } + +// now returns the current UTC time. An optional Go duration string +// offsets the result (e.g. "-720h" for 30 days ago). The returned +// time.Time value exposes its methods to templates, so callers can +// format it as needed: {{ (now).Format "2006-01-02" }}. +func now(offset ...string) (time.Time, error) { + t := time.Now().UTC() + if len(offset) == 0 { + return t, nil + } + d, err := time.ParseDuration(offset[0]) + if err != nil { + return time.Time{}, fmt.Errorf("invalid duration %q: %w", offset[0], err) + } + return t.Add(d), nil +} diff --git a/internal/httpserver/httpserver_test.go b/internal/httpserver/httpserver_test.go index f4af2d6..7ef0895 100644 --- a/internal/httpserver/httpserver_test.go +++ b/internal/httpserver/httpserver_test.go @@ -7,6 +7,7 @@ package httpserver import ( "bytes" "context" + "encoding/json" "fmt" "io" "io/ioutil" @@ -14,6 +15,7 @@ import ( "net/http" "os" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -66,6 +68,22 @@ func TestHTTPServer(t *testing.T) { "key2": "" } ` + "`" + `}} + + - path: "/time/now" + methods: ["GET"] + + responses: + - status_code: 200 + body: |- + {"ts": "{{ (now).Format "2006-01-02T15:04:05Z07:00" }}"} + + - path: "/time/offset" + methods: ["GET"] + + responses: + - status_code: 200 + body: |- + {"ts": "{{ (now "-720h").Format "2006-01-02T15:04:05Z07:00" }}"} ` f, err := ioutil.TempFile("", "test") @@ -164,6 +182,113 @@ func TestHTTPServer(t *testing.T) { assert.Equal(t, `{"key1":"value1","key2":""}`, string(body)) }) + + t.Run("now returns current time", func(t *testing.T) { + before := time.Now().UTC() + + req, err := http.NewRequest("GET", "http://"+addr+"/time/now", nil) + if err != nil { + t.Fatalf("NewRequest error: %v", err) + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("Do error: %v", err) + } + + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + t.Fatalf("ReadAll error: %v", err) + } + resp.Body.Close() + + // Extract the timestamp from {"ts": "2026-06-17T05:30:00Z"}. + var result struct{ Ts string } + if err := json.Unmarshal(body, &result); err != nil { + t.Fatalf("Unmarshal(%s) error: %v", body, err) + } + got, err := time.Parse(time.RFC3339, result.Ts) + if err != nil { + t.Fatalf("time.Parse(%q) error: %v", result.Ts, err) + } + if got.Before(before.Add(-time.Second)) || got.After(time.Now().UTC().Add(time.Second)) { + t.Errorf("now() = %s; want between %s and now", got, before) + } + }) + + t.Run("now with offset", func(t *testing.T) { + before := time.Now().UTC().Add(-720 * time.Hour) + + req, err := http.NewRequest("GET", "http://"+addr+"/time/offset", nil) + if err != nil { + t.Fatalf("NewRequest error: %v", err) + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("Do error: %v", err) + } + + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + t.Fatalf("ReadAll error: %v", err) + } + resp.Body.Close() + + var result struct{ Ts string } + if err := json.Unmarshal(body, &result); err != nil { + t.Fatalf("Unmarshal(%s) error: %v", body, err) + } + got, err := time.Parse(time.RFC3339, result.Ts) + if err != nil { + t.Fatalf("time.Parse(%q) error: %v", result.Ts, err) + } + if got.Before(before.Add(-time.Second)) || got.After(before.Add(time.Second)) { + t.Errorf("now(\"-720h\") = %s; want within 1s of %s", got, before) + } + }) +} + +func TestNow(t *testing.T) { + t.Run("no offset", func(t *testing.T) { + before := time.Now().UTC() + got, err := now() + if err != nil { + t.Fatalf("now() error: %v", err) + } + if got.Before(before.Add(-time.Second)) || got.After(time.Now().UTC().Add(time.Second)) { + t.Errorf("now() = %s; want within 1s of current time", got) + } + }) + + t.Run("negative offset", func(t *testing.T) { + before := time.Now().UTC().Add(-24 * time.Hour) + got, err := now("-24h") + if err != nil { + t.Fatalf("now(%q) error: %v", "-24h", err) + } + if got.Before(before.Add(-time.Second)) || got.After(before.Add(time.Second)) { + t.Errorf("now(%q) = %s; want within 1s of %s", "-24h", got, before) + } + }) + + t.Run("positive offset", func(t *testing.T) { + expected := time.Now().UTC().Add(2 * time.Hour) + got, err := now("2h") + if err != nil { + t.Fatalf("now(%q) error: %v", "2h", err) + } + if got.Before(expected.Add(-time.Second)) || got.After(expected.Add(time.Second)) { + t.Errorf("now(%q) = %s; want within 1s of %s", "2h", got, expected) + } + }) + + t.Run("invalid offset", func(t *testing.T) { + _, err := now("bogus") + if err == nil { + t.Error("now(\"bogus\") error = nil; want error") + } + }) } func TestRunAsSequence(t *testing.T) {