Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .changelog/205.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
```release-note:enhancement
http-server: Add `now` template function for dynamic timestamps with optional duration offset.
```
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
19 changes: 19 additions & 0 deletions internal/httpserver/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}
Comment thread
kcreddy marked this conversation as resolved.
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
}
125 changes: 125 additions & 0 deletions internal/httpserver/httpserver_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,15 @@
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"io/ioutil"
"net"
"net/http"
"os"
"testing"
"time"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
Expand Down Expand Up @@ -66,6 +68,22 @@
"key2": "<value2>"
}
` + "`" + `}}

- 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")
Expand Down Expand Up @@ -164,6 +182,113 @@

assert.Equal(t, `{"key1":"value1","key2":"<value2>"}`, 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)

Check failure on line 199 in internal/httpserver/httpserver_test.go

View workflow job for this annotation

GitHub Actions / lint

inline: Call of ioutil.ReadAll should be inlined (govet)
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 }

Check failure on line 206 in internal/httpserver/httpserver_test.go

View workflow job for this annotation

GitHub Actions / lint

ST1003: struct field Ts should be TS (staticcheck)
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)

Check failure on line 232 in internal/httpserver/httpserver_test.go

View workflow job for this annotation

GitHub Actions / lint

inline: Call of ioutil.ReadAll should be inlined (govet)
if err != nil {
t.Fatalf("ReadAll error: %v", err)
}
resp.Body.Close()

var result struct{ Ts string }

Check failure on line 238 in internal/httpserver/httpserver_test.go

View workflow job for this annotation

GitHub Actions / lint

ST1003: struct field Ts should be TS (staticcheck)
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) {
Expand Down
Loading